Skip to content

~ / blog / csharp-null-handling

C# Null Handling: Killing the Billion Dollar Mistake

C# 10 min read

Tony Hoare invented the null reference in 1965 and later called it his billion dollar mistake. NullReferenceException is still the most common crash in .NET applications, and it has a specific quality that makes it worse than other exceptions: it tells you where the program died, not where the bug is. A null created in a repository surfaces three layers away in a view. This post is about the tools C# now gives you to catch that at compile time instead.

Why null is a hole in the type system

From the value types post: a reference type variable holds an address, and null means “holds no address”. Historically, every reference type could be null, which means this signature is a lie:

public Book GetBook(int id)      // "returns a Book", but might return null

The type says Book. The runtime says Book or nothing. Callers cannot tell which methods can return null without reading the source or the documentation, so they either null-check everything defensively or nothing and hope.

Nullable reference types

C# 8 closed the hole. With NRTs enabled, the type system distinguishes the two cases:

public Book  GetBook(int id)      // never returns null, the compiler will hold you to it
public Book? FindBook(int id)     // may return null, callers must handle it

Turn it on project-wide in your .csproj: this is the default in new .NET templates and worth adding to older ones:

<Nullable>enable</Nullable>

Now the compiler tracks nullability through your code:

Book? book = FindBook(1);
Console.WriteLine(book.Title);          // warning: may be null here

if (book is not null)
    Console.WriteLine(book.Title);      // fine, flow analysis knows it is not null

That second block is flow analysis. The compiler follows the branches and knows that inside the if, book cannot be null. It understands null checks, pattern matches, early returns, assignments and throw statements.

Three things to be honest about:

They are warnings, not errors. The feature is compile-time only; nothing changes at runtime, and no runtime check is inserted. If you want them enforced, add <TreatWarningsAsErrors> for the nullable warnings, or at minimum treat CS8600–CS8655 as errors in CI. A warning nobody reads buys nothing.

They do not protect you at boundaries. Deserialised JSON, EF Core materialisation, reflection and old libraries can all put null into a non-nullable field, because there is no runtime enforcement. Validate at the edges of your system.

Enabling on an existing codebase produces hundreds of warnings. Do it per file with #nullable enable at the top, starting with new code and the domain model, rather than turning it on everywhere and suppressing the noise.

The operators

book?.Title                         // null-conditional: null if book is null, no throw
book?.Author?.Name                  // chains, stops at the first null
list?[0]                            // works on indexers too

name ?? "Unknown"                   // null-coalescing: use the right side if the left is null
name ??= "Unknown"                  // assign only if currently null

book!.Title                         // null-forgiving: "trust me, it isn't null"

?. short-circuits the whole chain, which is the part people underuse. a?.b.c.d returns null if a is null without evaluating anything after it: no need to repeat ?. at every step unless an intermediate member is itself nullable.

Note that ?. on a method returning a value type gives you a nullable: list?.Count is int?, not int. Pair it with ??:

var count = books?.Count ?? 0;

The dangerous one: !

The null-forgiving operator ! does not check anything. It silences the compiler:

var title = FindBook(1)!.Title;      // if it IS null, this throws at runtime

You have told the compiler you know better. If you are wrong, you get the exact NullReferenceException the feature existed to prevent, and you have removed the warning that would have caught it. Every ! is a claim you are making with no evidence attached.

There are two legitimate uses. Tests, where you are deliberately constructing a bad input. And cases where you genuinely know more than the flow analysis can see, typically because the check happened in a helper method, and the better fix there is to annotate the helper so the compiler learns:

public static bool IsValid([NotNullWhen(true)] Book? book) => book is not null && book.Price > 0;

if (IsValid(book))
    Console.WriteLine(book.Title);   // no warning, no !, the attribute taught the compiler

[NotNullWhen], [NotNullIfNotNull], [MemberNotNull] and friends are how the base class library annotates things like string.IsNullOrEmpty and Dictionary.TryGetValue. Using them on your own helpers removes most of the ! from a codebase.

Treat ! in a code review the way you would treat a suppressed warning: it needs a reason.

Fail fast at the boundary

The most valuable habit is not an operator, it is a guard clause. Reject null where it enters, so the stack trace points at the caller who supplied it:

public class BookService
{
    private readonly IBookRepository _repository;

    public BookService(IBookRepository repository)
    {
        ArgumentNullException.ThrowIfNull(repository);      // .NET 6+, one line
        _repository = repository;
    }

    public Book Get(int id)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id);
        return _repository.Find(id) ?? throw new BookNotFoundException(id);
    }
}

Two things there. ArgumentNullException.ThrowIfNull captures the parameter name automatically via [CallerArgumentExpression], so the message is useful. And ?? throw turns “returned null” into a specific, named exception at the point the problem occurred, which is the whole game. A BookNotFoundException naming the id is worth ten NullReferenceExceptions.

Inside the boundary, code can then assume its inputs are valid, which is what makes non-nullable annotations honest.

Avoiding null in the design

The operators handle null; good design produces less of it in the first place.

Return empty, not null, for collections. This is close to a universal rule:

public IReadOnlyList<Book> Search(string term)
{
    return _books.Where(...).ToList();       // empty list if nothing matched, never null
}

Every caller can then foreach without checking. A null collection has no advantages over an empty one; it just makes every call site do work. Use [], Array.Empty<T>() or Enumerable.Empty<T>() for the empty case.

Use the Try pattern for “might not exist”. It is the framework’s own idiom and it works with flow analysis:

if (repository.TryGet(id, out var book))
    Console.WriteLine(book.Title);           // compiler knows book is non-null here

Return a result type when failure has detail. For operations that can fail in interesting ways, a small result type beats null-plus-guesswork, and pairs well with the pattern matching post:

public readonly record struct Result<T>(bool Success, T? Value, string? Error);

var message = result switch
{
    { Success: true, Value: var b } => $"Found {b!.Title}",
    { Error: var e }                => $"Failed: {e}"
};

Several libraries provide a polished version of this, and F#‘s Option is the same idea built into the language.

The Null Object pattern, occasionally: return a harmless instance rather than null (NullLogger, EmptyBook, Guest) when every caller would otherwise write the same defensive check.

What NRTs do not cover

  • Value types were never nullable and are unaffected. int? is Nullable<int>, a completely separate mechanism.
  • Arrays. new string[10] gives you ten nulls even under a non-nullable string[] declaration; the compiler cannot track element initialisation.
  • Constructors that do not assign everything produce a warning, which is a useful signal: required properties (C# 11) are usually the right fix.
  • Anything crossing a serialisation, reflection or interop boundary. Validate it.

The same idea inside Kotlin, Swift and Rust

C# is a late adopter here, and it shows in the design: this is the “add it without breaking twenty years of code” version of a feature other languages built in from the start.

Kotlin (Google, Android). Nullability is in the type system and is enforced, not warned:

var a: String = "x"     // cannot be null, compile error if you try
var b: String? = null   // nullable
b?.length               // same safe-call operator
b ?: "default"          // Elvis operator, C#'s ??
b!!.length              // the null-forgiving operator, and it throws immediately

Kotlin’s !! is C#‘s !, with the same reputation. Android adopting Kotlin removed most NullPointerExceptions from new Android code, which is the strongest available evidence that this class of feature works.

Swift (Apple). Optional<T> is a real type you must unwrap, and if let / guard let are how you do it. There is no way to accidentally use an optional as if it were a value.

Rust. No null at all. Absence is Option<T>, Some(x) or None, and the compiler forces you to handle both arms. The billion dollar mistake is simply not expressible.

TypeScript (Meta). strictNullChecks is the closest analogue to C#‘s NRTs, including the “off by default for compatibility, on for new projects” history and the ! non-null assertion with the same warnings attached.

Java (Netflix). Still has no language-level nullability; the ecosystem relies on Optional<T> for return values and annotations like @Nullable interpreted by external tools. This is the counterfactual: it is what C# would still be dealing with without NRTs.

Summary

  • Historically every reference type could be null, so method signatures could not tell you whether a result was optional.
  • Nullable reference types make the difference visible: Book means never null, Book? means it might be.
  • Enable with <Nullable>enable</Nullable>. It is compile-time only (warnings, no runtime checks) so treat the warnings as errors and validate at boundaries.
  • Flow analysis tracks null checks, patterns and early returns, so a check narrows the type for the rest of the block.
  • ?. short-circuits a whole chain, ?? supplies a fallback, ??= assigns only if null.
  • ! silences the compiler without checking anything. Every use is an unevidenced claim; prefer annotations like [NotNullWhen] on helpers so the compiler can work it out.
  • Guard at the boundary with ArgumentNullException.ThrowIfNull and ?? throw new SpecificException(...) so the stack trace points at the cause.
  • Design null away: return empty collections, use the Try pattern, or return a result type.

Interview questions and answers

1. What are nullable reference types?

A C# 8 feature that lets the compiler distinguish between a reference that may be null (string?) and one that may not (string). It performs static flow analysis and warns when a possibly-null value is dereferenced or assigned to a non-nullable target. It is enabled per project with <Nullable>enable</Nullable> or per file with #nullable enable.

2. Do nullable reference types prevent NullReferenceException at runtime?

No. They generate compile-time warnings only; no runtime checks are emitted and the generated IL is unchanged. A value can still be null at runtime through deserialisation, reflection, EF Core materialisation, or a library compiled without the feature, so boundary validation is still required.

3. What is the difference between int? and string??

int? is Nullable<int>, a real struct with a HasValue flag that has existed since C# 2 and changes the type at runtime. string? is the same string type with a compile-time annotation attached; nothing about the runtime representation differs from string.

4. What do ?. and ?? do?

?. is the null-conditional operator: it evaluates the member access only if the target is non-null, otherwise the whole expression is null, and it short-circuits the rest of the chain. ?? is the null-coalescing operator: it returns its left operand unless it is null, in which case it returns the right. ??= assigns the right operand only when the target is currently null.

5. What does the null-forgiving operator do, and why is it discouraged?

x! tells the compiler to treat x as non-null and suppresses the warning. It performs no check, so if the value is null you get the NullReferenceException the warning was pointing at, with the warning now gone. It is acceptable in tests and where you provably know more than flow analysis, but annotating the helper with [NotNullWhen] is usually the better fix.

6. What are nullable attributes like [NotNullWhen]?

Attributes that describe nullability behaviour the compiler cannot infer. [NotNullWhen(true)] says a parameter is non-null when the method returns true, which is how string.IsNullOrEmpty and TryGetValue inform flow analysis. Others include [NotNullIfNotNull], [MemberNotNull] and [MaybeNull]. Applying them to your own helpers removes most need for !.

7. Should a method return null or an empty collection?

An empty collection, essentially always. Null forces every caller to write a check that adds nothing, and forgetting one causes a crash, whereas iterating an empty collection does nothing harmlessly. Return [], Array.Empty<T>() or an empty list.

8. How do you handle null at the boundaries of a system?

Validate on entry and fail fast with a specific exception, so the stack trace names the caller that supplied the bad value. ArgumentNullException.ThrowIfNull(x) captures the parameter name automatically, and ?? throw new NotFoundException(id) converts a null result into an exception that says what was missing.

9. What alternatives to null exist for representing absence?

The Try pattern with an out parameter, which flow analysis understands; a result type carrying success, value and error, which pairs naturally with pattern matching; and the Null Object pattern, returning a harmless instance such as a no-op logger. F# and other languages formalise this as Option/Maybe.

10. How do you adopt nullable reference types in an existing codebase?

Incrementally. Enable it per file with #nullable enable, starting with new code and the domain model, rather than switching it on solution-wide and drowning in warnings. Annotate public APIs first, since that is where the information helps callers most, and enable warnings-as-errors for nullable diagnostics once a project is clean so it does not regress.

# tags csharpnullnullable-reference-typesdotnetinterview-questions
$ share linkedin x / twitter

Tayyab Shahid, Senior Software Engineer in London

Tayyab Shahid

Senior Software Engineer & .NET Consultant · London, UK

about github linkedin

§ related posts

Building something like this?

I take on freelance .NET projects, consulting, and senior roles.

get in touch

← cd ~/blog

CV