Mert Özen Further With Every Line
Backend Advanced C# — #05

Nullable Reference Types in C# in Depth: NotNullWhen, MaybeNull and API Design

Mert Özen Nov 7, 2026 9 min 6 views
Nullable Reference Types in C# in Depth: NotNullWhen, MaybeNull and API Design

Flow analysis stopping at the method boundary and CS8602, a conditional contract with NotNullWhen and CS8762, an unconstrained T? staying plain T for value types, the generic Try pattern with MaybeNullWhen, NotNullIfNotNull, NotNull and AllowNull, MemberNotNull for CS8618, DoesNotReturn, the lack of runtime protection and ThrowIfNull, and #nullable and WarningsAsErrors for a gradual migration.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

The Bookstore catalog has a classic TryFind that looks up a book by ISBN. If it finds one it returns true and hands over the book through an out parameter. We compile the code in a project with nullable enabled. Does the compiler say anything about the book.Title line?

Catalog catalog = new Catalog();
catalog.Add(new Book("978-0441172719", "Dune"));

if (catalog.TryFind("978-0441172719", out Book? book))
{
    Console.WriteLine(book.Title);
}

public record Book(string Isbn, string Title);

public class Catalog
{
    private readonly Dictionary<string, Book> _books = new Dictionary<string, Book>();

    public void Add(Book book) => _books[book.Isbn] = book;

    public bool TryFind(string isbn, out Book? book) => _books.TryGetValue(isbn, out book);
}

You and I know that if it returned true, the book isn't null. But how would the compiler know that?

The Compiler Stops at the Method Boundary

The program runs and prints Dune, but there's a warning at build time:

Program.cs(6,23): warning CS8602: Dereference of a possibly null reference.

In the Fundamentals series we saw that nullable reference types are a flow analysis: inside a method, the compiler tracks whether each variable can be null at each point. But that analysis stops at the method boundary. The code calling TryFind doesn't look at its body, only at its signature. The signature says out Book? book, that is, "book may be null". The relationship between the return value and book isn't in the signature, so the compiler assumes the worst.

You could silence this warning with book!.Title, but then you'd have to write the same ! everywhere TryFind is used, and you'd blind the compiler's ability to catch real bugs. The right fix is to write the relationship into the signature. Since .NET Core 3.0, the System.Diagnostics.CodeAnalysis namespace has attributes for exactly this. They do nothing at runtime; their only job is to tell the compiler the null contract that the signature alone can't express.

A Conditional Contract: NotNullWhen

using System.Diagnostics.CodeAnalysis;

Catalog catalog = new Catalog();
catalog.Add(new Book("978-0441172719", "Dune"));

if (catalog.TryFind("978-0441172719", out Book? book))
{
    Console.WriteLine(book.Title);
}

string? note = catalog.NoteFor("978-0441172719");
if (!string.IsNullOrEmpty(note))
{
    Console.WriteLine(note.Length);
}

public record Book(string Isbn, string Title);

public class Catalog
{
    private readonly Dictionary<string, Book> _books = new Dictionary<string, Book>();

    public void Add(Book book) => _books[book.Isbn] = book;

    public string? NoteFor(string isbn) => isbn.StartsWith("978") ? "signed copy" : null;

    public bool TryFind(string isbn, [NotNullWhen(true)] out Book? book) => _books.TryGetValue(isbn, out book);

    public bool TryFindBroken(string isbn, [NotNullWhen(true)] out Book? book)
    {
        book = null;
        return true;
    }
}
Program.cs(32,9): warning CS8762: Parameter 'book' must have a non-null value when exiting with 'true'.
Dune
11

[NotNullWhen(true)] says "if the method returns true, this parameter isn't null". Now, inside the if block, book is known to be non-null and the book.Title warning is gone. The parameter's type is still Book?, because it can be null in the false case; the attribute separates the two cases.

The attribute is a promise, and the compiler checks the promise inside the method too. TryFindBroken got warning CS8762 because it leaves the value null and returns true. This check doesn't catch every case, but it catches the obvious contradictions.

The BCL uses these attributes throughout. The parameter of string.IsNullOrEmpty is marked [NotNullWhen(false)]; that's why note.Length compiled without a warning inside the !string.IsNullOrEmpty(note) block. The out parameter of Dictionary<TKey, TValue>.TryGetValue, on the other hand, carries [MaybeNullWhen(false)]. We'll see the difference between the two with generics.

T? and MaybeNullWhen in Generics

Null is a separate headache in generic code. Since C# 9 you can write T? for an unconstrained T, but it may not mean what you think:

int[] stock = { 3, 0, 12 };
string[] titles = { "Dune", "Clean Code" };

Console.WriteLine(FindFirst(stock, s => s > 100));
Console.WriteLine(FindFirst(titles, t => t.StartsWith("X")) ?? "(null)");

static T? FindFirst<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (T item in source)
    {
        if (predicate(item))
        {
            return item;
        }
    }
    return default;
}
0
(null)

No book has more than 100 in stock, yet the result isn't null, it's 0. An unconstrained T? means "may be null" for reference types; for value types it doesn't turn into Nullable<T>, it stays plain T. Called with int, the return type became int and default returned 0. "Not found" and "found a book with stock 0" can't be told apart. LINQ's FirstOrDefault behaves the same way. If the distinction matters, go back to the Try pattern:

using System.Diagnostics.CodeAnalysis;

Cache<int> stockCache = new Cache<int>();
stockCache.Set("dune", 3);

Cache<Book> bookCache = new Cache<Book>();
bookCache.Set("dune", new Book("Dune"));

if (stockCache.TryGet("dune", out int count))
{
    Console.WriteLine($"stock: {count}");
}

if (bookCache.TryGet("dune", out Book? book))
{
    Console.WriteLine($"title: {book.Title}");
}

Console.WriteLine(bookCache.TryGet("missing", out Book? missing) ? missing.Title : "not cached");

public record Book(string Title);

public class Cache<T>
{
    private readonly Dictionary<string, T> _items = new Dictionary<string, T>();

    public void Set(string key, T value) => _items[key] = value;

    public bool TryGet(string key, [MaybeNullWhen(false)] out T value) => _items.TryGetValue(key, out value);
}
stock: 3
title: Dune
not cached

Here the parameter type is T, not T?. We don't want to store a null value in a Cache<Book>, so the type says "not null". [MaybeNullWhen(false)] adds the exception: "if the method returns false, this value may be null (or default)". NotNullWhen conditionally narrows a nullable type, MaybeNullWhen conditionally widens a non-nullable one. In a generic API the second is more accurate, because T may itself be a nullable type like string?, and you don't spoil its nullability.

Input and Output Attributes

Another group of attributes describes how a value behaves on its way into and out of a method:

using System.Diagnostics.CodeAnalysis;

string normalized = Isbn.Normalize("978-0-441-17271-9");
string? nothing = Isbn.Normalize(null);
Console.WriteLine(normalized);
Console.WriteLine(nothing ?? "(null)");

string? raw = Environment.GetEnvironmentVariable("BOOKSTORE_DEFAULT_ISBN") ?? "9780441172719";
Isbn.EnsureValid(raw);
Console.WriteLine(raw.Length);

BookNote bookNote = new BookNote();
bookNote.Text = null;
Console.WriteLine($"[{bookNote.Text}] {bookNote.Text.Length}");

public static class Isbn
{
    [return: NotNullIfNotNull("isbn")]
    public static string? Normalize(string? isbn) => isbn?.Replace("-", "");

    public static void EnsureValid([NotNull] string? isbn)
    {
        if (isbn is null || isbn.Length != 13)
        {
            throw new ArgumentException("ISBN must have 13 digits.", nameof(isbn));
        }
    }
}

public class BookNote
{
    private string _text = "";

    [AllowNull]
    public string Text
    {
        get => _text;
        set => _text = value ?? "";
    }
}
9780441172719
(null)
13
[] 0

All three compiled without warnings. [return: NotNullIfNotNull("isbn")]: "if the input isn't null, the output isn't null either". Passing a non-null string let us assign the result directly to a string; passing null left the result nullable. We wrote the parameter name as a string, because reaching a parameter with nameof inside an attribute arrived in C# 11.

[NotNull] on a parameter means "if the method returns normally, this value is no longer null". Since EnsureValid throws for null, raw is known to be non-null on the line after the call. [AllowNull] lets a property's setter accept null while keeping its getter non-null: you can assign null to Text, and reading it gives you an empty string. [DisallowNull], which does the opposite ("nullable type, but don't give me null"), and [MaybeNull] for return values belong to the same family.

Constructor Helpers: MemberNotNull

There's a common warning in classes that move their constructor setup into a helper method:

OrderSession session = new OrderSession("Ada");
Console.WriteLine(session.Summary);

public class OrderSession
{
    private List<string> _items;
    private string _customerName;

    public OrderSession(string customerName)
    {
        Reset(customerName);
    }

    public string Summary => $"{_customerName}: {_items.Count} items";

    public void Reset(string customerName)
    {
        _items = new List<string>();
        _customerName = customerName;
    }
}
Program.cs(9,12): warning CS8618: Non-nullable field '_customerName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
Program.cs(9,12): warning CS8618: Non-nullable field '_items' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
Ada: 0 items

The fields are assigned, but inside Reset; once again the compiler stops at the method boundary. The required suggestion in the message is a C# 11 feature, so it's not an option for us. The fix is to write down what Reset guarantees:

[MemberNotNull(nameof(_items), nameof(_customerName))]
public void Reset(string customerName)

After adding this line and putting using System.Diagnostics.CodeAnalysis; at the top of the file, both warnings went away. The compiler checks the promise inside Reset too: if you forget to assign one of the fields, this time Reset gets the warning. For fields tied to a bool property there's also [MemberNotNullWhen(true, nameof(_x))]; that's how you say the data is loaded when IsLoaded is true.

Methods That End the Flow: DoesNotReturn

Collecting exception throwing in a single helper is a common habit. The compiler, however, doesn't know the helper never returns:

Console.WriteLine(Describe(new Book("Dune")));

static string Describe(Book? book)
{
    if (book is null)
    {
        Fail("A book is required.");
    }
    return book.Title;
}

static void Fail(string message) => throw new ArgumentException(message);

public record Book(string Title);
Program.cs(9,12): warning CS8602: Dereference of a possibly null reference.
Dune

As far as the compiler knows, Fail can return and book can still be null. Writing [DoesNotReturn] above Fail made the warning go away; the compiler now knows there's no way out of the if block. Its sibling [DoesNotReturnIf(false)] goes on a bool parameter; the condition parameter of Debug.Assert is marked this way.

No Protection at Runtime

All of this happens at compile time. Nullable reference types add not a single check to the runtime; string and string? are the same type in compiled code. Old code with nullable disabled, or someone silencing a warning with !, can pass null to a non-nullable parameter:

try
{
    Book book = LegacyImporter.Import();
    Console.WriteLine(book.Title.Length);
}
catch (ArgumentNullException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

public record Book
{
    public string Title { get; }

    public Book(string title)
    {
        ArgumentNullException.ThrowIfNull(title);
        Title = title;
    }
}

#nullable disable
public static class LegacyImporter
{
    public static Book Import()
    {
        string title = null;
        return new Book(title);
    }
}
#nullable restore
ArgumentNullException: Value cannot be null. (Parameter 'title')

In the #nullable disable region the compiler gave no warning, and null made it all the way to Book's constructor. There, ArgumentNullException.ThrowIfNull, new in .NET 6, caught it. We didn't have to write the parameter name: thanks to C# 10's [CallerArgumentExpression] attribute, the method gets the expression passed as the argument, title, by itself. The rule: at the boundary of a public API, nullable annotations guide the reader, while ThrowIfNull actually protects. Neither replaces the other.

These directives also help when moving a large project to nullable: you can enable it project-wide and put #nullable disable at the top of files not yet migrated, or the other way round, move file by file with #nullable enable in a project where it's off. Once the migration is done, adding <WarningsAsErrors>nullable</WarningsAsErrors> to the csproj turns every nullable warning into an error and makes the gain permanent.

Common Pitfalls

Silencing with null! and default!. private string _name = null!; tells the compiler "trust me"; on the day that trust turns out misplaced, you get the NullReferenceException somewhere with no warning at all. Express with attributes every contract you can express with attributes.

Taking an unconstrained T? for Nullable<T>. For value types T? is plain T; if you want to tell "missing" apart from default, use the Try pattern or a real T? with where T : struct.

Putting the attribute the wrong way round. Mixing up [NotNullWhen(true)] and [NotNullWhen(false)] gives the compiler wrong information, and that lie isn't caught on the calling side. Internal checks like CS8762 catch the obvious cases; tests catch the rest.

Taking annotations for runtime checks. If you're writing a library, you can't assume the code calling you has nullable enabled. Keep doing null checks in public methods.

A Small Experiment

★☆☆ Add bool TryFindByTitle(string title, [NotNullWhen(true)] out Book? book) to Catalog and print book.Isbn without a warning inside the if block. Then change the attribute to [NotNullWhen(false)] and see where the warning moves.

★★☆ Next to FindFirst<T>, write a FindFirstValue<T> constrained with where T : struct that returns T?. See that calling it with the stock array now gives you null. Can the two methods have the same name?

★★★ Add public string? InvoiceNumber { get; private set; } to OrderSession, and a public bool IsCheckedOut => InvoiceNumber is not null; marked with [MemberNotNullWhen(true, nameof(InvoiceNumber))]. On the calling side, see that session.InvoiceNumber.Length compiles without a warning inside an if (session.IsCheckedOut) block, and that CS8602 comes back when you remove the attribute.

In this post we used attributes only to inform the compiler. But attributes can be read at runtime too; ASP.NET Core's [HttpGet], EF Core's [Key] and the validation attributes work that way. In the next post we write our own attribute and read it with reflection.