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:
<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:
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:
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:
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:
// 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:
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:
- Enable per-file using
#nullable enableat the top of files you are actively working on. - Fix warnings as you encounter them, starting with your most critical code paths.
- 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:
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.