Nullable Reference Types

Turning on nullable reference types makes string mean never null and string? mean it might, with compiler warnings doing the work.

Syntax<Nullable>enable</Nullable> in the .csproj, or #nullable enable in one file

A reference is the address of an object, not the object itself. A reference variable can also hold null, meaning it points at nothing. Using a null reference — calling a method on it, reading a property, indexing into it — fails at run time with NullReferenceException. For years the compiler said nothing about it, because string meant "a string, or null, and good luck".

Putting <Nullable>enable</Nullable> in the project file changes that, and every project from dotnet new already has it. Now string means never null and string? means it might be. The compiler tracks which one each expression is at every line, and warns when the code and the declaration disagree.

What the warnings mean

Three come up constantly. Using a string? without checking it is CS8602, "dereference of a possibly null reference". Putting a possibly-null value into a string is CS8600. Leaving a non-nullable field or property unset by the end of a constructor is CS8618. The fix is almost never to silence the warning: either check for null, or change the declaration to string? because null really is allowed there.

What trips people up

This is a compile-time feature only. string and string? are the same type at run time, nothing is verified while the program runs, and the ? changes no behaviour. Values arriving from JSON, a database driver, or an older library can be null in a variable the compiler believes is safe. #nullable disable switches the checks off for the rest of a file, which is how large codebases migrate one file at a time.

Example

Example · csharp
var ada = new Person("Ada", "King", "Lovelace");
var grace = new Person("Grace", null, "Hopper");

Console.WriteLine(FullName(ada));
Console.WriteLine(FullName(grace));

static string FullName(Person person)
{
    // MiddleName is declared string?, so using it without a check
    // is warning CS8602. This check removes the warning.
    if (person.MiddleName is null)
    {
        // FirstName and LastName are declared string, so the compiler
        // knows they are never null and asks for no check.
        return $"{person.FirstName} {person.LastName}";
    }

    return $"{person.FirstName} {person.MiddleName} {person.LastName}";
}

record Person(string FirstName, string? MiddleName, string LastName);

// Output:
// Ada King Lovelace
// Grace Hopper

When to use it

  • Turn the feature on in an older project and work through the warnings file by file.
  • State in the type whether a request property is required or optional.
  • Fix a CS8602 warning by adding the check it is asking for rather than suppressing it.
  • Annotate a validation helper so callers stop needing the ! operator.

More examples

How a check clears a warning

This is the everyday loop of working with the feature: the declared type sets the starting point, and a check narrows it for the code that follows. The compiler follows if statements, is-patterns, loops and early returns, so you rarely need to restructure anything. Note that the declaration drives the warning, not the value: Lookup really does return a string here, but its type says it might not.

Example · csharp
string? maybe = Lookup("ada");

// Writing maybe.Length here would be warning CS8602, because the
// compiler only knows the declared type: string?.
if (maybe is not null)
{
    // Inside this block it knows better, so there is no warning.
    Console.WriteLine(maybe.Length);
}

// A method taking string? and returning string has to cover the
// null case, or it would be warning CS8603 on the return.
Console.WriteLine(Describe(maybe));

// The return type is string? because one arm yields null.
static string? Lookup(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null
};

static string Describe(string? value) => value ?? "(unknown)";

// Output:
// 12
// Ada Lovelace

Teaching the compiler about your own helper

The analysis does not look inside method bodies, so by default a bool returned from your own validation helper tells the compiler nothing. The attributes in System.Diagnostics.CodeAnalysis close that gap: NotNullWhen, MemberNotNull, NotNullIfNotNull and others. Every API in the .NET libraries has carried these annotations since .NET 5, which is why string.IsNullOrEmpty already works this way. Add one whenever a helper of yours leaves callers reaching for the ! operator.

Example · csharp
using System.Diagnostics.CodeAnalysis;

Console.WriteLine(Render("hello"));
Console.WriteLine(Render(null));

static string Render(string? value)
{
    if (Text.IsPresent(value))
    {
        // No warning and no ! needed. The attribute on IsPresent told
        // the compiler that value is not null when it returns true.
        return value.ToUpperInvariant();
    }

    return "(empty)";
}

static class Text
{
    // [NotNullWhen(true)] is a promise about the argument, checked
    // by the compiler at every call site.
    public static bool IsPresent([NotNullWhen(true)] string? value) =>
        !string.IsNullOrEmpty(value);
}

// Output:
// HELLO
// (empty)

The holes in the analysis

Two documented limits. A new array of a non-nullable reference type starts full of nulls, and a struct created with default or new() leaves its non-nullable reference fields null, both without a warning. The three directives are #nullable enable, #nullable disable, and #nullable restore, which goes back to whatever the project file says. Use disable only on a file you are migrating, and treat each one as a to-do rather than a solution.

Example · csharp
// Both lines below compile with no nullable warning at all,
// and both values are null.
string[] names = new string[3];
Console.WriteLine($"names[0] is null: {names[0] is null}");

// A collection expression fills every slot, so nothing is null.
string[] filled = ["a", "b", "c"];
Console.WriteLine($"filled[0] is null: {filled[0] is null}");

#nullable disable
// Inside a disabled region the annotations are ignored and no
// nullable warnings are reported at all.
string legacy = null;
#nullable restore

Console.WriteLine($"legacy is null: {legacy is null}");

// Output:
// names[0] is null: True
// filled[0] is null: False
// legacy is null: True

Discussion

  • Be the first to comment on this lesson.