Nullable Reference Types: Eliminating the Billion-Dollar Mistake

Tony Hoare famously called null references his "billion-dollar mistake." C# has lived with this mistake since its inception — any reference type could be null, and the compiler had no way to help you track it. Nullable reference types, enabled by default since .NET 6 project templates, change that equation fundamentally.

Enabling the Feature

Nullable reference types are controlled by the <Nullable> project setting:

YourProject.csproj
<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

With this enabled, the compiler treats all reference types as non-nullable by default. To indicate that a variable may be null, you annotate it with ?, just as you would with a value type:

Example.cs
string name = "Alice";    // Non-nullable — compiler warns if you assign null
string? nickname = null;   // Nullable — explicitly permits null

How the Compiler Helps

The compiler performs flow analysis to track nullability state. It warns when you dereference a possibly-null value and when you assign null to a non-nullable variable:

Example.cs
public void Greet(string? name)
{
    // Warning CS8602: Dereference of a possibly null reference
    Console.WriteLine(name.Length);

    // Safe — null check changes the flow state
    if (name is not null)
    {
        Console.WriteLine(name.Length); // No warning
    }
}

The analysis understands common null-checking idioms: is not null, != null, string.IsNullOrEmpty(), and pattern matching all narrow the type correctly.

Annotating Your APIs

The real power comes from annotating method signatures. When you declare a parameter as non-nullable, callers receive warnings if they pass a possibly-null value:

Example.cs
public class UserService
{
    public User GetUser(string id)
    {
        // Return type is non-nullable: you promise to never return null
        return _repository.FindById(id)
            ?? throw new InvalidOperationException($"User {id} not found");
    }

    public User? FindUser(string id)
    {
        // Return type is nullable: callers must handle null
        return _repository.FindById(id);
    }
}

This creates a self-documenting API. Callers of GetUser know they will always receive a valid User. Callers of FindUser know they must check for null.

Common Annotations

The System.Diagnostics.CodeAnalysis namespace provides attributes for cases where flow analysis cannot infer nullability:

Example.cs
// Tells the compiler that if the method returns true, 'result' is not null
public bool TryGetValue(string key, [NotNullWhen(true)] out string? result)
{
    result = _dictionary.GetValueOrDefault(key);
    return result is not null;
}

// Tells the compiler that 'value' will not be null after this method returns
public void EnsureNotNull([NotNull] string? value)
{
    if (value is null)
        throw new ArgumentNullException(nameof(value));
}

// Tells the compiler the return is not null if the parameter is not null
[return: NotNullIfNotNull(nameof(input))]
public string? Normalise(string? input)
{
    return input?.Trim().ToLowerInvariant();
}

These attributes bridge the gap between what the compiler can infer and what you know about your code's behaviour.

The Null-Forgiving Operator

Sometimes you know more than the compiler. The ! operator suppresses nullable warnings:

Example.cs
string name = GetPossiblyNullName()!; // "Trust me, this won't be null"

Use this sparingly. Every ! is a spot where you have taken responsibility away from the compiler. If you find yourself using it frequently, your nullability annotations probably need improving.

Migration Strategy

For existing projects, enabling nullable reference types across the entire codebase at once can produce thousands of warnings. A practical approach:

  1. Enable per-file using #nullable enable at the top of files you are actively working on.
  2. Fix warnings as you encounter them, starting with your most critical code paths.
  3. Enable project-wide once the majority of files are clean.

You can also use #nullable disable to temporarily exclude files that are not yet ready.

Limitations to Understand

Nullable reference types are a compile-time feature. They do not change runtime behaviour — a non-nullable reference can still be null at runtime if it receives a value from unannotated code, reflection, or deserialisation. They are warnings, not guarantees.

This means you should still validate inputs at public API boundaries:

Example.cs
public void ProcessOrder(Order order)
{
    ArgumentNullException.ThrowIfNull(order);
    // Proceed with confidence
}

Despite this limitation, nullable reference types dramatically reduce null-related bugs in practice. They make nullability part of your type system's vocabulary, turning implicit assumptions into explicit declarations that the compiler can verify. For any new C# project, enabling them from the start is an unambiguous win.