Skip to main content

Command Palette

Search for a command to run...

Be Expressive: Null Handling

Null handling in C#

Updated
7 min read
Be Expressive: Null Handling

TL;DR

C# developers have better options than relying on null. Use value types for guaranteed validity, Option<T> in libraries like LanguageExt for explicit absence, required/init for complete object construction, and Nullable Reference Types for safer references. Together, these tools reduce ambiguity, improve safety, and make intent clear. With native discriminated unions on the horizon for C#, modeling data is becoming even more expressive.

Tony Hoare coined the term "Null is a Billion-Dollar Mistake" and for good reason. Null is ambiguous. Does it mean not found, not applicable, not initialized, or just a bug? Null leads to countless runtime errors, with NullReferenceException consistently topping .NET error logs. It also encourages defensive programming, cluttering code with checks like if (x is not null) or if (x == null). Fortunately, modern C# offers better alternatives, including those introduced through third-party libraries.

Value Types

Value types require that all data be provided at the time of construction. Either a default constructor must exist, or every field must be initialized before leaving the constructor. This allows the compiler to enforce validity up front. A properly defined value type like ZipCode can never be null. Instead, it can be safely defaulted, for instance using ZipCode.Default, and later validated using an IsDefault accessor.

The downside is the boilerplate involved. Nearly every such type must be defined manually. However, libraries like NewType and ValueOf help reduce this overhead. They make it easier to create expressive value types, with optional validation logic, without writing repetitive code.

Value types are essentially structs where their values are encapsulated and any information must be explicitly accessed. An example of such a value type is a ZipCode type, which can have either a five-digit or ZIP+4 (nine-digit) code.

using System.Text.RegularExpressions;

readonly record struct ZipCode {
    private readonly ReadOnlyMemory<char> _value;

    private ZipCode(string value) => _value = value.AsMemory();

    public static ZipCode Create(string value) =>
        ZipCodeRegex.Match(value) switch {
            { Groups: [_, { Success: true } fst, _, { Success: true } lst] } => 
                new ($"{fst.Value}-{lst.Value}"),
            { Groups: [_, { Success: true } fst, _, _] } => new (fst.Value),
            _ => default
        };

    public bool IsDefault => _value.IsEmpy

    public override string ToString() => _value.ToString();

    static readonly Regex ZipCodeRegex =
        new ("^([0-9]{5})(-([0-9]{4}))?$");
}

var zip = ZipCode.Create("33431");

The savvy .NET developer might point out that one could call new ZipCode() or have an array of codes that are not initialized, such as new ZipCode[5]. That developer is correct. However, the internal value is not null in these cases and is safe to act on.

Expressive Absence with Option<T>

The Option<T> type offers a safer and more expressive way to model values that may or may not be present. Rather than returning null, which forces guesswork and defensive checks, an API that returns Option<User> is making its contract explicit: a user may exist, or the result may be absent, and the caller is responsible for handling both.

This explicitness removes ambiguity and shifts absence from an implementation detail to a part of the type system. The result is more declarative, correct, and predictable code. Rather than defensively reacting to potential null, you proactively engage with known possibilities. Adopting Option<T> does introduce a small learning curve and usually a third-party library, but the clarity and safety it adds often outweigh those costs. Looking ahead, Microsoft’s plans to bring discriminated unions, and potentially a native Option<T> type, to C# may reduce those barriers.

We can represent that a field in a record might not be present and that a value returned from a function can also represent absence.

A compact, drop-in example (compiles with LanguageExt)

using LanguageExt;
using static LanguageExt.Prelude;

record Author(string FirstName, Option<string> MiddleInitial, string LastName);

Option<Author> GetAuthor(string lastName) {
    Author[] authors = [
        new("Ernest", None, "Hemingway"),
        new("Charles", None, "Dickens")
    ];

    foreach(var a in authors)
        if(author.LastName == lastName) return Some(a);

    return None;
};

Enforcing Presence with required and init

C# has introduced additional tools to help prevent uninitialized and partially initialized objects from entering the system. The required and init keywords are designed to encourage complete construction and immutable design. These keywords do not replace Option<T> or value types; instead, they complement them by guaranteeing required members are always set before use.

By default, all fields in C# record types must be initialized through the constructor. That is, all fields must be set during the type's construction. Fields in records can use explicit accessors without the need to specify the default constructor.

One could have an Author record in which the first name and last name need to be provided at construction, while the middle initial does not have to be set. However, due to the use of init, the value cannot be set once the type has been initialized. If a required field is not initialized during instantiation, the compiler produces an error and prevents the project from building.

record Author {
    public required string FirstName { get; init; }
    public string? MiddleInitial { get; init; }
    public required string LastName { get; init; }
}

var author1 = new Author {
    FirstName = "Charls",
    LastName = "Dickens"
};

var author2 = new Author {
    FirstName = "Ernest"
}; //. Will error as LastName is not set

Nullable Reference Types (NRTs)

C# 8 introduced nullable reference types, which allow developers to specify whether a given reference can hold null. When enabled, the compiler will warn you when you try to dereference a value that might be null, or fail to assign to a non-nullable property.

string name = "Mark";    // non-nullable
string? nickname = null; // nullable

These annotations provide a layer of static analysis that encourages safer handling of reference types. You can enable them per file with #nullable enable, or globally within a project:

<Nullable>enable</Nullable>

Once enabled, the compiler distinguishes between string and string?, making intent clearer and usage safer. You are prompted to consider where null might flow through your system and address it accordingly.

This model is a significant improvement over the historical default where all references could be null implicitly. It reduces accidents and helps signal the need for validation. However, it falls short of offering true modeling power. Nullable reference types do not prevent runtime nulls. They are enforced through warnings, not type errors, which can be ignored or suppressed. They do not compose. You cannot map, match, or bind nullable references in a fluent, predictable way. Their meaning is also less precise. A string? might mean “optional” or it might just mean “uninitialized.” The type system cannot differentiate.

As a result, NRTs are a useful baseline but not a replacement for union types like Option<T>. They can be used together, and in many codebases, enabling nullable annotations is a good first step. But when your domain logic demands clear expression of absence, when you want types that communicate and behavior that composes, Option<T> remains the better choice.

Enable NRTs to reduce accidental nulls, but use Option<T> when you want to express absence as part of your model.

Native Discriminated Unions (Coming to C#)

C# is moving closer to providing native support for discriminated unions. Discriminated unions are also known as “type unions," which will allow developers to define types that represent a value in one of several named cases, each potentially carrying its own data.

Although discriminated unions are not yet available in C# 14 or .NET 10, they are an active topic for the C# language design team. Proposals and working group notes outline how the feature might look in future versions of the language.

Below is a peek into Microsoft's proposal:

public union Option<T> {
    case Some(T Value);
    case None;
}

A union enables exhaustive pattern matching at compile time, so when you use a switch expression over a union, the compiler ensures all cases are handled. Such an approach is significantly safer and more expressive than traditional inheritance-based patterns.

var pet = Some(new Pet("Oliver", "cat"));

_ = pet switch {
    Some(value) => ...,
    None => ...
};

Feel free to learn more about unions by reading Microsoft's Union proposals overview.

Conclusion

Null has long been a source of ambiguity and runtime bugs in C#. With the combined use of value types, Option<T>, required/init, and nullable reference types, developers have a richer toolbox for designing safer, more expressive code. And with discriminated unions being developed for future C# versions, the language is taking another major step toward making absence and alternatives explicit in the type system rather than hidden in runtime behavior. Together, these features move C# closer to a future where intent is clear, bugs are reduced, and code is easier to reason about.

Taming Nulls in C#

Part 2 of 2

Null is a billion-dollar mistake. This two-part series explores why nulls pollute code and how C# tools like value types, Option<T>, and nullable references help us design absence explicitly and write safer, expressive code.

Start from the beginning

Nulls Should Be Exceptional

Introduction Null reference exceptions are the norm, they are not exceptional. For many developers, handling nothing (null) has become a mundane routine built into the mind's muscle memory. A few "if" statements there, a guard condition here, and yet...