Update libraries to use nullable reference types and communicate nullable rules to callers

The addition of nullable reference types means you can declare whether or not a null value is allowed or expected for every variable. In addition, you can apply a number of attributes: AllowNull, DisallowNull, MaybeNull, NotNull, NotNullWhen, MaybeNullWhen, and NotNullWhenNotNull to completely describe the null states of argument and return values. That provides a great experience as you write code. You get warnings if a non-nullable variable might be set to null. You get warnings if a nullable variable isn't null-checked before you dereference it. Updating your libraries can take time, but the payoffs are worth it. The more information you provide to the compiler about when a null value is allowed or prohibited, the better warnings users of your API will get. Let's start with a familiar example. Imagine your library has the following API to retrieve a resource string:

bool TryGetMessage(string key, out string message)

The preceding example follows the familiar Try* pattern in .NET. There are two reference arguments for this API: the key and the message parameter. This API has the following rules relating to the nullness of these arguments:

  • Callers shouldn't pass null as the argument for key.
  • Callers can pass a variable whose value is null as the argument for message.
  • If the TryGetMessage method returns true, the value of message isn't null. If the return value is false, the value of message (and its null state) is null.

The rule for key can be completely expressed by the variable type: key should be a non-nullable reference type. The message parameter is more complex. It allows null as the argument, but guarantees that, on success, that out argument isn't null. For these scenarios, you need a richer vocabulary to describe the expectations.

Updating your library for nullable references requires more than sprinkling ? on some of the variables and type names. The preceding example shows that you need to examine your APIs and consider your expectations for each input argument. Consider the guarantees for the return value, and any out or ref arguments upon the method's return. Then communicate those rules to the compiler, and the compiler will provide warnings when callers don't abide by those rules.

This work takes time. Let's start with strategies to make your library or application nullable-aware, while balancing other requirements and deliverables. You'll see how to balance ongoing development enabling nullable reference types. You'll learn challenges for generic type definitions. You'll learn to apply attributes to describe pre- and post-conditions on individual APIs.

Choose a nullable strategy

The first choice is whether nullable reference types should be on or off by default. You have two strategies:

  • Enable nullable reference types for the entire project, and disable it in code that's not ready.
  • Only enable nullable reference types for code that's been annotated for nullable reference types.

The first strategy works best when you're adding other features to the library as you update it for nullable reference types. All new development is nullable aware. As you update existing code, you enable nullable reference types in those classes.

Following this first strategy, you do the following:

  1. Enable nullable types for the entire project by adding the <Nullable>enable</Nullable> element to your csproj files.
  2. Add the #nullable disable pragma to every source file in your project.
  3. As you work on each file, remove the pragma and address any warnings.

This first strategy has more up-front work to add the pragma to every file. The advantage is that every new code file added to the project will be nullable enabled. Any new work will be nullable aware; only existing code must be updated.

The second strategy works better if the library is generally stable, and the main focus of the development is to adopt nullable reference types. You turn on nullable reference types as you annotate APIs. When you've finished, you enable nullable reference types for the entire project.

Following this second strategy you do the following:

  1. Add the #nullable enable pragma to the file you want to make nullable aware.
  2. Address any warnings.
  3. Continue these first two steps until you've made the entire library nullable aware.
  4. Enable nullable types for the entire project by adding the <Nullable>enable</Nullable> element to your csproj files.
  5. Remove the #nullable enable pragmas, as they're no longer needed.

This second strategy has less work up-front. The tradeoff is that the first task when you create a new file is to add the pragma and make it nullable aware. If any developers on your team forget, that new code is now in the backlog of work to make all code nullable aware.

Which of these strategies you pick depends on how much active development is taking place in your project. The more mature and stable your project, the better the second strategy. The more features being developed, the better the first strategy.

Should nullable warnings introduce breaking changes?

Before you enable nullable reference types, variables are considered nullable oblivious. Once you enable nullable reference types, all those variables are non-nullable. The compiler will issue warnings if those variables aren't initialized to non-null values.

Another likely source of warnings is return values when the value hasn't been initialized.

The first step in addressing the compiler warnings is to use ? annotations on parameter and return types to indicate when arguments or return values may be null. When reference variables must not be null, the original declaration is correct. As you do this, your goal isn't just to fix warnings. The more important goal is to make the compiler understand your intent for potential null values. As you examine the warnings, you reach your next major decision for your library. Do you want to consider modifying API signatures to more clearly communicate your design intent? A better API signature for the TryGetMessage method examined earlier could be:

string? TryGetMessage(string key);

The return value indicates success or failure, and carries the value if the value was found. In many cases, changing API signatures can improve how they communicate null values.

However, for public libraries, or libraries with large user bases, you may prefer not introducing any API signature changes. For those cases, and other common patterns, you can apply attributes to more clearly define when an argument or return value may be null. Whether or not you consider changing the surface of your API, you'll likely find that type annotations alone aren't sufficient for describing null values for arguments or return values. In those instances, you can apply attributes to more clearly describe an API.

Attributes extend type annotations

Several attributes have been added to express additional information about the null state of variables. All code you wrote before C# 8 introduced nullable reference types was null oblivious. That means any reference type variable may be null, but null checks aren't required. Once your code is nullable aware, those rules change. Reference types should never be the null value, and nullable reference types must be checked against null before being dereferenced.

The rules for your APIs are likely more complicated, as you saw with the TryGetValue API scenario. Many of your APIs have more complex rules for when variables can or can't be null. In these cases, you'll use one of the following attributes to express those rules:

  • AllowNull: A non-nullable input argument may be null.
  • DisallowNull: A nullable input argument should never be null.
  • MaybeNull: A non-nullable return value may be null.
  • NotNull: A nullable return value will never be null.
  • MaybeNullWhen: A non-nullable input argument may be null when the method returns the specified bool value.
  • NotNullWhen: A nullable input argument will not be null when the method returns the specified bool value.
  • NotNullIfNotNull: A return value isn't null if the argument for the specified parameter isn't null.

The preceding descriptions are a quick reference to what each attribute does. Each following section describes the behavior and meaning more thoroughly.

Adding these attributes gives the compiler more information about the rules for your API. When calling code is compiled in a nullable enabled context, the compiler will warn callers when they violate those rules. These attributes don't enable additional checks on your implementation.

Specify preconditions: AllowNull and DisallowNull

Consider a read/write property that never returns null because it has a reasonable default value. Callers pass null to the set accessor when setting it to that default value. For example, consider a messaging system that asks for a screen name in a chat room. If none is provided, the system generates a random name:

public string ScreenName
{
   get => screenName;
   set => screenName = value ?? GenerateRandomScreenName();
}
private string screenName;

When you compile the preceding code in a nullable oblivious context, everything is fine. Once you enable nullable reference types, the ScreenName property becomes a non-nullable reference. That's correct for the get accessor: it never returns null. Callers don't need to check the returned property for null. But now setting the property to null generates a warning. In order to continue to support this type of code, you add the System.Diagnostics.CodeAnalysis.AllowNullAttribute attribute to the property, as shown in the following code:

[AllowNull]
public string ScreenName
{
   get => screenName;
   set => screenName = value ?? GenerateRandomScreenName();
}
private string screenName = GenerateRandomScreenName();

You may need to add a using directive for System.Diagnostics.CodeAnalysis to use this and other attributes discussed in this article. The attribute is applied to the property, not the set accessor. The AllowNull attribute specifies pre-conditions, and only applies to inputs. The get accessor has a return value, but no input arguments. Therefore, the AllowNull attribute only applies to the set accessor.

The preceding example demonstrates what to look for when adding the AllowNull attribute on an argument:

  1. The general contract for that variable is that it shouldn't be null, so you want a non-nullable reference type.
  2. There are scenarios for the input variable to be null, though they aren't the most common usage.

Most often you'll need this attribute for properties, or in, out, and ref arguments. The AllowNull attribute is the best choice when a variable is typically non-null, but you need to allow null as a precondition.

Contrast that with scenarios for using DisallowNull: You use this attribute to specify that an input variable of a nullable type shouldn't be null. Consider a property where null is the default value, but clients can only set it to a non-null value. Consider the following code:

public string ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string _comment;

The preceding code is the best way to express your design that the ReviewComment could be null, but can't be set to null. Once this code is nullable aware, you can express this concept more clearly to callers using the System.Diagnostics.CodeAnalysis.DisallowNullAttribute:

[DisallowNull] 
public string? ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string? _comment;

In a nullable context, the ReviewComment get accessor could return the default value of null. The compiler warns that it must be checked before access. Furthermore, it warns callers that, even though it could be null, callers shouldn't explicitly set it to null. The DisallowNull attribute also specifies a pre-condition, it does not affect the get accessor. You should choose to use the DisallowNull attribute when you observe these characteristics about:

  1. The variable could be null in core scenarios, often when first instantiated.
  2. The variable shouldn't be explicitly set to null.

These situations are common in code that was originally null oblivious. It may be that object properties are set in two distinct initialization operations. It may be that some properties are set only after some asynchronous work has completed.

The AllowNull and DisallowNull attributes enable you to specify that preconditions on variables may not match the nullable annotations on those variables. These provide more detail about the characteristics of your API. This additional information helps callers use your API correctly. Remember you specify preconditions using the following attributes:

  • AllowNull: A non-nullable input argument may be null.
  • DisallowNull: A nullable input argument should never be null.

Specify post-conditions: MaybeNull and NotNull

Suppose you have a method with the following signature:

public Customer FindCustomer(string lastName, string firstName)

You've likely written a method like this to return null when the name sought wasn't found. The null clearly indicates that the record wasn't found. In this example, you'd likely change the return type from Customer to Customer?. Declaring the return value as a nullable reference type specifies the intent of this API clearly.

For reasons covered under Generic definitions and nullability that technique does not work with generic methods. You may have a generic method that follows a similar pattern:

public T Find<T>(IEnumerable<T> sequence, Func<T, bool> match)

You can't specify that the return value is T?. The method returns null when the sought item isn't found. Since you can't declare a T? return type, you add the MaybeNull annotation to the method return:

[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> match)

The preceding code informs callers that the contract implies a non-nullable type, but the return value may actually be null. Use the MaybeNull attribute when your API should be a non-nullable type, typically a generic type parameter, but there may be instances where null would be returned.

You can also specify that a return value or an out or ref argument isn't null even though the type is a nullable type. Consider a method that ensures an array is large enough to hold a number of elements. If the input argument doesn't have capacity, the routine would allocate a new array and copy all the existing elements into it. If the input argument is null, the routine would allocate new storage. If there's sufficient capacity, the routine does nothing:

public void EnsureCapacity<T>(ref T[] storage, int size)

You could call this routine as follows:

// messages has the default value (null) when EnsureCapacity is called:
EnsureCapacity<string>(ref messages, 10);
// messages is not null.
EnsureCapacity<string>(messages, 50);

After enabling null reference types, you want to ensure that the preceding code compiles without warnings. When the method returns, the storage argument is guaranteed to be not null. However, it's acceptable to call EnsureCapacity with a null reference. You can make storage a nullable reference type, and add the NotNull post-condition to the parameter declaration:

public void EnsureCapacity<T>([NotNull]ref T[]? storage, int size)

The preceding code expresses the existing contract very clearly: Callers can pass a variable with the null value, but the return value is guaranteed to never be null. The NotNull attribute is most useful for ref and out arguments where null may be passed as an argument, but that argument is guaranteed to be not null when the method returns.

You specify unconditional postconditions using the following attributes:

  • MaybeNull: A non-nullable return value may be null.
  • NotNull: A nullable return value will never be null.

Specify conditional post-conditions: NotNullWhen, MaybeNullWhen, and NotNullIfNotNull

You're likely familiar with the string method String.IsNullOrEmpty(String). This method returns true when the argument is null or an empty string. It's a form of null-check: Callers don't need to null-check the argument if the method returns false. To make a method like this nullable aware, you'd set the argument to a nullable type, and add the NotNullWhen attribute:

bool IsNullOrEmpty([NotNullWhen(false)]string? value);

That informs the compiler that any code where the return value is false need not be null-checked. The addition of the attribute informs the compiler's static analysis that IsNullOrEmpty performs the necessary null check: when it returns false, the input argument is not null.

string? userInput = GetUserInput();
if (!(string.IsNullOrEmpty(userInput))
{
   int messageLength = userInput.Length; // no null check needed.
}
// null check needed on userInput here.

The String.IsNullOrEmpty(String) method will be annotated as shown above for .NET Core 3.0. You may have similar methods in your codebase that check the state of objects for null values. The compiler won't recognize custom null check methods, and you'll need to add the annotations yourself. When you add the attribute, the compiler's static analysis knows when the tested variable has been null checked.

Another use for these attributes is the Try* pattern. The postconditions for ref and out variables are communicated through the return value. Consider this method shown earlier:

bool TryGetMessage(string key, out string message)

The preceding method follows a typical .NET idiom: the return value indicates if message was set to the found value or, if no message is found, to the default value. If the method returns true, the value of message isn't null; otherwise, the method sets message to null.

You can communicate that idiom using the NotNullWhen attribute. When you update the signature for nullable reference types, make message a string? and add an attribute:

bool TryGetMessage(string key, [NotNullWhen(true)] out string? message)

In the preceding example, the value of message is known to be not null when TryGetMessage returns true. You should annotate similar methods in your codebase in the same way: the arguments could be null, and are known to be not null when the method returns true.

There's one final attribute you may also need. Sometimes the null state of a return value depends on the null state of one or more input arguments. These methods will return a non-null value whenever certain input arguments aren't null. To correctly annotate these methods, you use the NotNullIfNotNull attribute. Consider the following method:

string GetTopLevelDomainFromFullUrl(string url);

If the url argument isn't null, the output isn't null. Once nullable references are enabled, that signature works correctly, provided your API never accepts a null input. However, if the input could be null, then return value could also be null. Therefore, you could change the signature to the following code:

string? GetTopLevelDomainFromFullUrl(string? url);

That also works, but will often force callers to implement extra null checks. The contract is that the return value would be null only when the input argument url is null. To express that contract, you would annotate this method as shown in the following code:

[return: NotNullIfNotNull("url")]
string? GetTopLevelDomainFromFullUrl(string? url);

The return value and the argument have both been annotated with the ? indicating that either could be null. The attribute further clarifies that the return value won't be null when the url argument isn't null.

You specify conditional postconditions using these attributes:

  • MaybeNullWhen: A non-nullable input argument may be null when the method returns the specified bool value.
  • NotNullWhen: A nullable input argument will not be null when the method returns the specified bool value.
  • NotNullIfNotNull: A return value isn't null if the input argument for the specified parameter isn't null.

Generic definitions and nullability

Correctly communicating the null state of generic types and generic methods requires special care. This stems from the fact that a nullable value type and a nullable reference type are fundamentally different. An int? is a synonym for Nullable<int>, whereas string? is string with an attribute added by the compiler. The result is that the compiler can't generate correct code for T? without knowing if T is a class or a struct.

This doesn't mean you can't use a nullable type (either value type or reference type) as the type argument for a closed generic type. Both List<string?> and List<int?> are valid instantiations of List<T>.

What it does mean is that you can't use T? in a generic class or method declaration without constraints. For example, Enumerable.FirstOrDefault<TSource>(IEnumerable<TSource>) won't be changed to return T?. You can overcome this limitation by adding either the struct or class constraint. With either of those constraints, the compiler knows how to generate code for both T and T?.

You may want to restrict the types used for a generic type argument to be non-nullable types. You can do that by adding the notnull constraint on that type argument. When that constraint is applied, the type argument must not be a nullable type.

Conclusions

Adding nullable reference types provides an initial vocabulary to describe your APIs expectations for variables that could be null. The additional attributes provide a richer vocabulary to describe the null state of variables as preconditions and postconditions. These attributes more clearly describe your expectations and provide a better experience for the developers using your APIs.

As you update libraries for a nullable context, add these attributes to guide users of your APIs to the correct usage. These attributes help you fully describe the null-state of input arguments and return values:

  • AllowNull: A non-nullable input argument may be null.
  • DisallowNull: A nullable input argument should never be null.
  • MaybeNull: A non-nullable return value may be null.
  • NotNull: A nullable return value will never be null.
  • MaybeNullWhen: A non-nullable input argument may be null when the method returns the specified bool value.
  • NotNullWhen: A nullable input argument will not be null when the method returns the specified bool value.
  • NotNullIfNotNull: A return value isn't null if the input argument for the specified parameter isn't null.