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 the required 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.

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

Popular Posts