Nullable Reference Types Are Not The Answer

20 September 2026

Microsoft's nullable reference types are often presented as a major improvement to C#. I disagree. They create a large amount of visual noise, provide only compile-time assurances, and encourage developers to treat a warning system as though it were a proper way to model absence.

The real problem is simple: sometimes a value exists, and sometimes it does not. That is a meaningful state in a program's domain. It deserves to be represented clearly, deliberately, and consistently—not encoded by decorating otherwise ordinary reference types with punctuation.

For me, readability is everything. Code is read vastly more often than it is written, and its meaning should be apparent to the next person who encounters it—often its original author, months later. A feature that adds symbols everywhere while making contracts less clear is moving in the wrong direction, even if it produces useful compiler warnings.

A warning system, not a runtime guarantee

Nullable reference types do not change what a string is at runtime. A variable declared as string can still be null; a variable declared as string? is also still a reference that can be null. The distinction exists primarily for the compiler's static-flow analysis.

string name = null; // Warning, but still possible.

The compiler can warn us about a likely null dereference, and that is sometimes useful. But it cannot prevent null values originating from deserialization, reflection, legacy libraries, unannotated dependencies, external input, or code that suppresses warnings with !.

var name = response.Name!;

That postfix ! is particularly revealing. When the compiler becomes inconvenient, we can simply tell it to trust us. Nothing is validated. Nothing is changed at runtime. We have merely silenced the warning.

Nullable reference types are therefore not null safety. They are annotations for a static checker. Calling that an answer to null handling is generous.

The name is backwards

"Nullable reference types" is a terrible name for this feature. Reference types were always nullable, and they remain nullable now. The runtime representation of a string? is no different from a string: both can contain null.

If the name described the intention rather than the implementation, it would be something like "non-nullable reference types". The feature's purpose is to let us tell the compiler that a particular reference is expected not to be null, and to have it warn when its flow analysis cannot support that expectation.

But even that name would overstate the guarantee. A string in a nullable-enabled project is not actually non-nullable. It can still receive null at runtime, and the null-forgiving operator lets developers override the compiler whenever they want:

string name = response.Name!;

The ! does not make Name non-null. It does not insert a guard, throw a useful exception at the boundary, or validate the incoming data. It simply says, "compiler, do not warn me about this." A feature named for non-nullability that can be bypassed by punctuation is not establishing non-nullability at all.

Code Contracts showed a better direction

Microsoft's Code Contracts initiative was a much more compelling approach. It let code state its invariants directly, supported static analysis, and could also enforce preconditions and postconditions at runtime. The same contract could serve as executable documentation, a static-analysis input, and a runtime boundary check.

For example, an API that requires an identifier and guarantees a result can express both facts without putting nullable punctuation throughout its signature:

using System.Diagnostics.Contracts;

public sealed class CustomerService
{
    private readonly ICustomerRepository customerRepository;

    public CustomerService(ICustomerRepository customerRepository)
    {
        Contract.Requires<ArgumentNullException>(customerRepository is not null);

        this.customerRepository = customerRepository;
    }

    [ContractInvariantMethod]
    private void ObjectInvariant()
    {
        Contract.Invariant(customerRepository is not null);
    }

    public Customer FindCustomer(string customerId)
    {
        Contract.Requires<ArgumentException>(
            !string.IsNullOrWhiteSpace(customerId),
            "A customer ID is required.");
        Contract.Ensures(Contract.Result<Customer>() is not null);

        var customer = customerRepository.Find(customerId);

        if (customer is null)
        {
            throw new CustomerNotFoundException(customerId);
        }

        return customer;
    }
}

The precondition says what the caller must provide. The postcondition says what the method promises after it returns. Crucially, the runtime checker could enforce those promises rather than merely suggesting that a warning be investigated later.

Code Contracts had its limitations and required tooling support, but it pointed towards cleaner code and stronger guarantees. Microsoft added the System.Diagnostics.Contracts namespace to .NET, then allowed the broader initiative and its tooling to wither. In its place, nullable reference types offer a weaker convention: useful warnings, certainly, but no runtime validation and no meaningful model for an expected absence.

How I use contracts today

The lack of Microsoft's original tooling does not make the idea less useful. TA.Utils.Core includes its own runtime CodeContracts helpers. They evaluate the supplied condition, and a violated contract throws CodeContractViolationException with the condition, failed value, and calling member attached for diagnosis.

For example, Octet is an immutable eight-bit value type. Its private constructor states the conditions that must hold before it accepts its backing array:

private Octet(bool[] bits)
{
    bits.ContractAssertNotNull();
    bits.ContractAssert(p => p.Length == 8, "Octet must have exactly 8 bits");
    this.bits = bits;
}

This is cleaner than relying on an annotation and hoping it is honoured. The first assertion makes the non-null requirement explicit and checks it at runtime; the second captures the more important domain invariant that an octet contains exactly eight bits. The class can also use the original Code Contracts notation to state its persistent invariants:

[ContractInvariantMethod]
private void ObjectInvariant()
{
    Contract.Invariant(MajorVersion >= 0);
    Contract.Invariant(MinorVersion >= 0);
    Contract.Invariant(PatchVersion >= 0);
    Contract.Invariant(BuildVersion != null);
    Contract.Invariant(PrereleaseVersion != null);
}

That is the invariant in TA.Utils.Core.SemanticVersion: a semantic version cannot exist in an invalid state. BuildVersion and PrereleaseVersion are themselves Maybe<string>, so an absent tag is modelled as an empty Maybe<string>, not a null reference.

Punctuation is not domain modelling

Once nullable reference types are enabled, ? starts spreading through every layer of an application:

public Customer? FindCustomer(string customerId);
public string? DisplayName { get; init; }
public Address? Address { get; init; }
public Task<Order?> GetOrderAsync(string orderId);

Every ? asks the reader to stop and consider whether absence is intended, accidental, temporary, externally supplied, or simply not yet understood by the compiler. Often that information is not available from the type alone.

A nullable reference says only that the value might be null. It does not say what that means.

Does null mean:

  • The customer was not found?
  • The lookup failed?
  • The user is anonymous?
  • The field was omitted from an API payload?
  • The value has not been loaded yet?
  • The value is unavailable because of permissions?
  • Someone forgot to initialise a property?

Those cases are not interchangeable, but nullable reference types collapse them into one ambiguous representation: null.

This does not make code clearer. It makes uncertainty contagious. Readability is everything, and ? often tells us less about the meaning of an absent value than a properly named type and an explicit contract would.

Maybe<T> makes absence explicit

When a value may or may not exist, I prefer Timtek.Utils.Core.Maybe<T>.

public Maybe<Customer> FindCustomer(string customerId);

This states an important fact about the operation: it may produce a customer, or it may produce no customer. Absence is part of the result, rather than an undocumented special value.

The caller must then handle that result intentionally:

var customer = customerService.FindCustomer(customerId);

if (customer.Any())
{
    return CreateResponse(customer.Single());
}

return NotFound();

The result is more readable than a nullable reference plus a defensive check:

var customer = customerService.FindCustomer(customerId);

if (customer is null)
{
    return NotFound();
}

return CreateResponse(customer);

The difference is not merely stylistic. Maybe<T> communicates that an operation can validly have no result. A nullable reference communicates only that null might happen.

The type should describe the outcome

A method that cannot find an entity has not necessarily failed. "No result" can be an expected and valid outcome.

That is exactly what Maybe<T> models:

Maybe<Order> FindOrder(OrderId orderId);

By contrast, this type leaves too much unsaid:

Order? FindOrder(OrderId orderId);

With Maybe<T>, code can compose absence handling deliberately instead of scattering null checks through the application. It becomes possible to transform, filter, bind, and provide defaults without repeatedly opening an if (value is not null) block.

More importantly, the API makes the contract visible. Consumers cannot mistake "not found" for a valid object whose members happen to be empty or null.

Nullable reference types create false confidence

The most dangerous feature of nullable reference types may be psychological. A project with nullable warnings enabled can look safer than it really is.

Developers start seeing string and assuming it cannot be null. But that assumption only holds if every boundary is correctly annotated, every warning is addressed rather than suppressed, every serializer behaves as expected, every library supplies accurate metadata, and no external system provides an unexpected value.

That is a lot of trust to place in a compiler feature that does not alter runtime behaviour.

A non-nullable reference annotation can be helpful documentation and a useful warning signal. But it is not an invariant. It is not validation. It is not a replacement for modelling optional values properly.

Use the right representation

null is still sometimes unavoidable, especially at system boundaries and when working with frameworks or external libraries. Nullable reference types can help identify those places.

But they should not become the primary language for application-level absence.

When "there may be no value" is an expected outcome, use Maybe<T>. It makes the contract explicit, keeps intent in the type system, avoids nullable punctuation spreading through the codebase, and forces absence to be handled where it matters.

Nullable reference types are an abomination. Turn them off and write cleaner, intention-revealing code instead. Use Maybe<T> to model potential absence. Use ContractAssert methods for runtime argument validation and invariant checking.

csharp opinion code-contracts clean-code readability