Attendance Required
One easily overlooked feature of C# is the required keyword for properties. Introduced in C# v11.0 with .NET v7.0, its purpose is to make it easier to write defensive code that fails at compile-time if it’s misused, rather than at run-time with an obscure exception. That is certainly a worthy goal, because compiler errors are easier and less embarrassing to fix than an unexpected failure in a demo to a customer or in production. However, there is more to required than meets the unaided eye, and it’s not the magic potion that it might at first appear to be.
So, how is therequired
keyword useful? Consider this simple data type:
public readonly struct Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public DateOnly DOB { get; init; }
}
The Person
struct uses
init
-only
setters for the properties, meaning those properties can be given a value only during construction
of a Person
. The obvious way to create a Person
is to use the
object-initializer
syntax like this:
var person = new Person { FirstName = "Arthur", LastName = "Dent" };
Assert.That(person.DOB, Is.Not.EqualTo(DateOnly.MinValue));
This code compiles, but the test fails because the object initialization doesn’t initialize the
person.DOB
property (if you’re not familiar with DateOnly
, it was introduced in
.NET v6.0
as a companion to the DateTime
type).
It’s the kind of error that the compiler should be able to report, and with
the introduction of the required
keyword, it can. A property that is required
must be given a
value when creating an object. Here we modify Person
so that all its properties have the
required
keyword:
public readonly struct Person
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required DateOnly DOB { get; init; }
}
All the properties of Person
have the required
keyword, meaning that they must all be given a
value when creating a Person
. Forgetting to initialize a property results in a compile-time error.
Our original test code fails to compile because the person.DOB
property isn’t initialized:
var person = new Person { FirstName = "Arthur", LastName = "Dent" };
Assert.That(person.DOB, Is.Not.EqualTo(DateOnly.MinValue));
Error CS9035 : Required member 'Person.DOB' must be set in the object initializer or attribute constructor.
This kind of error is so common that having the compiler catching and reporting it seems altogether
a good thing. However, required
has its limitations.
Defence Against null
Just because a property is required
does not mean it must be non-null
:
var person = new Person { FirstName = null, LastName = null, DOB = default };
Here the object initializer expression provides a value for all the properties, so the code compiles—but it does give a warning:
Warning CS8625 : Cannot convert null literal to non-nullable reference type.
This warning applies only to the two string
properties, neither of which is a
nullable reference (first
introduced in C# v8.0). The fact that DOB
is explicitly initialized to a default
value
escapes the compiler’s notice altogether.
Constructors
Note that Person
is a struct
, and so can be default initialized; as you would expect, creating a
default(Person)
doesn’t provoke an error even in the presence of the required
keyword—by
definition, a default Person
has default values for all its properties. Perhaps more surprising is
that attempting to create a default person via the
default
parameterless constructor doesn’t compile:
var person = new Person();
This code provokes the CS9035
error for each property that hasn’t been set. Since C# v10.0 (.NET
v6.0)
user-defined
default struct constructors are allowed; we could use such a constructor to initialize each
property to a non-default value:
public readonly struct Person
{
public Person()
=> (FirstName, LastName, DOB) = ("", "", DateOnly.MaxValue);
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required DateOnly DOB { get; init; }
}
var person = new Person();
Here the call to new Person()
invokes our explicitly defined constructor, but we still get the
CS9035
error. The reason for that is that the required
keyword applies to object
initialization, and not to construction. We have the same problem if we use
inline
property initialization. The inline initializers for fields and properties are part of
construction, and the compiler doesn’t take their presence to mean they satisfy the needs of a
required
property.
Even if we write a constructor taking parameters for every property and initialize the properties from those parameters, the compiler is still not satisfied.
To address this problem we need to add an attribute to a constructor that tells the compiler that all the properties are initialized by the constructor, whether or not the constructor takes parameters:
public readonly struct Person
{
[System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
public Person()
=> (FirstName, LastName, DOB) = ("", "", DateOnly.MaxValue);
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required DateOnly DOB { get; init; }
}
Here we’ve added the SetsRequiredMembers
attribute to the parameterless constructor, and now the
code to initialize Person
with no arguments compiles just fine. There is still a problem,
however. We’ve told the compiler that the cvonstructor initializes all the required
properties,
and the compiler believes us unconditionally.
A Counter-Example
Consider the following Person
type that initializes the properties from the constructor parameters:
public readonly struct Person
{
[System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
public Person(string firstName, string lastName, DateOnly dob)
=> (FirstName, LastName, DOB) = (firstName, lastName, dob);
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required DateOnly DOB { get; init; }
public required string Pronoun { get; init; }
}
var person = new Person("Arthur", "Dent", DateOnly.FromDateTime(DateTime.Now));
Here we have used the SetsRequiredMembers
attribute to indicate that all the required properties
are initialized, but look closely: we’ve also added a new Pronoun
property that isn’t
initialized by the constructor.
Does this code compile?
Yes, it compiles, leaving the Pronoun
property with its default value of null
. It turns out that
required
doesn’t always mean required!
Primary and Positional Constructors
Required properties don’t play very nicely with
primary
constructors (something I’ve written about
previously),
either. This version of the Person
struct uses a primary constructor instead of a normal
constructor:
public readonly struct Person(string firstName, string lastName, DateOnly dob)
{
public required string FirstName { get; init; } = firstName;
public required string LastName { get; init; } = lastName;
public required DateOnly DOB { get; init; } = dob;
}
This version fails to compile—again with the CS9035
error—but there is nowhere to put the
SetsRequiredMembers
attribute: it’s only valid on a constructor definition.
There’s also no way to make the compiler-generated properties of a
positional
record or record struct type required
. Consider the record struct version of the Person
type:
public readonly record struct Person(string FirstName, string LastName, DateOnly DOB);
There’s nowhere to introduce required
unless we write our own properties, sacrificing one of the
neatest features of record types, and it still suffers the problem of not being able to add the
SetsRequiredMembers
attribute.
Fully Featured?
The required
feature then is useful for the most simple types that are intended to only be created
using the object initialization syntax. That’s a bit limiting.
Simple types like Person
are very common, with a few properties that describe the basic attributes
of the type. In particular, they’re popular as
data-transfer objects, but they’re not
particularly well encapsulated. For instance, there’s no validation of the properties' values here;
we might want to ensure that the FirstName
and LastName
properties don’t contain invalid
characters (I’ve never seen a name that contains _
, for example), or that the DOB
is within a
reasonable range. Over-using simple DTO types like Person
means that any validation has to be done
in the code that uses it, risking duplication of the code, or (worse) different validation in
different places. Martin Fowler describes a system that has an over-reliance on such types an
anemic design.
Looking at the
feature
specification for required
, it seems its primary motivation is to reduce typing…but typing
isn’t the main bottleneck in my experience. All in all, I think the required
feature represents a
missed opportunity.
Photo by Alexander Grey on Unsplash
Comments
Post a Comment