← Open in the full interactive course (progress tracking, search & more)

Null used to be everyone's problem at runtime. Now the compiler argues with you about it at compile time — which is a much cheaper place to have the argument.

In 2009, Sir Tony Hoare — the computer scientist who invented the null reference back in 1965 — publicly apologized for it. He called it his "billion-dollar mistake." Not because null itself is useless, but because every mainstream language made it too easy to forget that a reference might be null, and too easy to write code that blows up the moment it isn't.

If you've written any C#, you already know the feeling: everything compiles, everything looks fine, and then at 2 AM in production you get a NullReferenceException because some Customer.Address.City chain hit a null link nobody thought to check. The compiler never warned you. It had no idea Address could be null — as far as it was concerned, every reference type could always be null, so there was nothing special to flag.

In this lesson, you'll learn what Nullable Reference Types (NRT) are, why C# added them, how #nullable enable changes the rules of the game, and how the compiler now catches a huge class of null-reference bugs before your code ever runs.

What Is It?

The Simple Explanation

Nullable Reference Types is a C# feature that lets you tell the compiler, right in your type declarations, whether a reference is allowed to be null or not. A plain string now means "this will always have a value." A string? means "this might be null — you'd better check before you use it."

The compiler then watches how you use each variable and warns you the moment you might be about to dereference something that could be null — before you ever run the program.

The Technical Definition

Nullable Reference Types (NRT) is a C# 8 compiler feature that adds static (compile-time) null-state analysis and annotations to reference types. Inside a nullable-enabled context (turned on with #nullable enable or the project-wide <Nullable>enable</Nullable> setting), every reference type is treated as non-nullable by default. You opt a specific type into nullability by suffixing it with ?, exactly like you already do for value types (int?).

Crucially, this is annotation and flow analysis, not enforcement. The compiler tracks the "null state" of every variable through your code — assigned, possibly-null, definitely-not-null — and issues warnings (not errors, by default) when your code doesn't match what it declared. Nothing about it changes what runs; it changes what the compiler is willing to let slide silently.

Nullable Disabled (the old default)

Nullable Enabled (the modern default)

Why Does It Exist?

The Problem

Before C# 8, every reference type variable — string, Customer, List<T>, anything that wasn't a struct — could always be null, and the compiler had no opinion about it. This created two related pains:

Developers responded by either defensively null-checking everything (bloating code with checks that are almost always unnecessary) or defensively checking nothing (and hoping). Neither is great. NullReferenceException was — and in code without NRT enabled, still is — one of the most common runtime errors in .NET applications.

The Need

What developers actually needed was a way to encode "can this be null?" directly into the type system — the same way C# already lets you say int (never null) versus int? (nullable) for value types — so the compiler could catch the mistake at the earliest, cheapest possible moment: while you're typing the code, not while a customer is using it.

The Solution

C# 8 introduced Nullable Reference Types. Once you opt in (and every new .NET project template opts in by default since .NET 6), the compiler:

Big Picture

Here's the shift in where a null problem gets caught:

WITHOUT NRT vs WITH NRT

Without NRT

You write code

Compiler: no opinion, builds fine

Ships to production

A null slips through at runtime

NullReferenceException

With NRT

You write code

Compiler tracks null-state of every reference

Possible null dereference detected

Build-time warning, right in your editor

You fix it before it ships

How It Works

NRT STEP BY STEP
1. TURN ON A NULLABLE CONTEXT
// In the .csproj (project-wide, default in .NET 6+ templates):
<Nullable>enable</Nullable>

// Or per-file, at the top of a .cs file:
#nullable enable
2. DECLARE INTENT WITH THE TYPE
public class Customer
{
    public string Name { get; set; }      // never null — required
    public string? MiddleName { get; set; } // optional — may be null
}
3. THE COMPILER TRACKS NULL-STATE (FLOW ANALYSIS)
void Print(Customer c)
{
    Console.WriteLine(c.MiddleName.Length); //  warning: MiddleName may be null

    if (c.MiddleName != null)
    {
        Console.WriteLine(c.MiddleName.Length); //  no warning — narrowed to non-null here
    }
}
4. WARNINGS SHOW UP AT BUILD TIME AND IN YOUR EDITOR

Simple Example

Before — nullable context disabled (the pre-C#-8 world)

public class Order
{
    public string CustomerEmail { get; set; }
}

void SendReceipt(Order order)
{
    // Compiles perfectly fine. No warning.
    // If CustomerEmail was never set, this throws NullReferenceException at runtime.
    Console.WriteLine(order.CustomerEmail.ToUpper());
}

After — nullable context enabled

#nullable enable

public class Order
{
    public required string CustomerEmail { get; set; } // must be provided — never null
}

void SendReceipt(Order order)
{
    // No warning here — CustomerEmail is guaranteed non-null by its type.
    Console.WriteLine(order.CustomerEmail.ToUpper());
}

Meaning: In the "after" version, the compiler itself guarantees CustomerEmail is never null by the time you reach SendReceipt — because anywhere it could have been left null (a missing constructor argument, a missing initializer) would have produced a warning first. You've moved the bug from "discovered by a user" to "flagged by your editor while you type."

Real-World Example

Imagine an API layer that receives a CustomerDto from an HTTP request body and needs to send a welcome email. Without NRT, a missing Email field in the incoming JSON is only discovered when the email service tries to use it:

#nullable enable

public class CustomerDto
{
    public required string Name { get; set; }
    public required string Email { get; set; }   // must always have a value
    public string? PhoneNumber { get; set; }      // genuinely optional
}

public class CustomerController
{
    private readonly IEmailService _emailService;

    public CustomerController(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public void Register(CustomerDto dto)
    {
        // No null-check needed for dto.Email — the type system already guarantees it.
        _emailService.SendWelcomeEmail(dto.Email, dto.Name);

        // PhoneNumber IS nullable, so the compiler forces us to think about the null case:
        if (dto.PhoneNumber is not null)
        {
            _emailService.SendSmsConfirmation(dto.PhoneNumber, dto.Name);
        }
    }
}

If the JSON deserializer produces a CustomerDto with a missing Email, that's now a deserialization or validation problem to catch at the boundary — the rest of the codebase gets to trust the type. PhoneNumber, on the other hand, is honestly optional, so its ? forces every caller to handle the "no phone number" case explicitly instead of hoping nobody forgets.

Analogy

A labeled box vs an unlabeled box

Imagine every variable is a box being handed to you. In the old world, every box might be empty, and nothing on the outside tells you which ones are. You either open every box cautiously (defensive null-checks everywhere) or you assume it's full and get burned when it isn't (a crash).

Nullable reference types put a label on the box before it's handed to you: "Guaranteed full" (string) or "May be empty — check first" (string?). You still have to actually look inside a labeled-empty box before using its contents — the label doesn't magically fill it — but now you know, at a glance, which boxes need that caution and which don't.

Under the Hood

WHAT NRT ACTUALLY IS AT RUNTIME
1. IT'S A COMPILE-TIME-ONLY ANNOTATION
2. THE ANNOTATIONS ARE PRESERVED VIA ATTRIBUTES
3. NO RUNTIME ENFORCEMENT AT ALL

Common Confusion

1. "Nullable reference types" vs "nullable value types" — same ?, different mechanism

int? is genuinely a different type at runtime: it's shorthand for Nullable<int>, a struct that wraps an int plus a boolean flag saying whether it has a value. That mechanism has existed since C# 2 and is enforced at runtime.

string? is not a different runtime type from string — it's purely a compile-time annotation understood by the compiler's flow analysis. Same symbol, two very different mechanisms depending on whether it follows a value type or a reference type.

2. "My build succeeded, so there are no null bugs" — warnings aren't errors by default

Nullable warnings don't stop the build by default. It's easy to skim past them, especially in a large codebase that already has a backlog of them from before NRT was enabled. Many teams eventually promote specific nullable warnings to build errors (<WarningsAsErrors>CS8600;CS8602;CS8618</WarningsAsErrors>, for example) once the codebase is clean, precisely so they can't silently creep back in.

3. "Enabling NRT makes old code stop working" — it doesn't

Turning on <Nullable>enable</Nullable> in an existing project produces warnings, not compile errors, for every place your code doesn't line up with strict nullability. The program still builds and runs exactly as before. You get to fix things gradually, at your own pace, rather than facing an all-or-nothing migration.

Common Mistakes

Mistake 1 — Silencing every warning with ! instead of fixing the real issue

Suppressing a warning with the null-forgiving operator without actually verifying the value can't be null:

string city = customer.Address.City!; // "trust me, compiler" — but is it actually true?

Instead, actually handle the possibility, or fix the design so the value genuinely can't be null (see the following lesson on null-safe programming for the full toolkit of ?., ??, and pattern checks).

Mistake 2 — Declaring a property non-nullable but never actually initializing it

public class Customer
{
    public string Name { get; set; } //  CS8618: non-nullable property must contain a non-null value when exiting the constructor
}

Provide the value through a constructor, a required modifier, or a default value — don't just ignore the warning, because it means an actual gap exists between what you promised and what you delivered.

Mistake 3 — Assuming NRT is a runtime guarantee

Removing legitimate runtime validation (e.g. at an API boundary, deserializing untrusted JSON) just because the C# type says "non-nullable." Data coming from outside your process — HTTP requests, database rows, third-party libraries compiled without nullable annotations — can still hand you a null even though your C# type claims otherwise.

Keep validating at trust boundaries. NRT protects you from careless mistakes inside your own code; it is not a substitute for input validation.

When Should I Use It?

Rule of thumb: Treat every nullable-warning as real information, not noise. If the compiler says a value "may be null," either prove to it that it isn't (with a check), or acknowledge that it genuinely can be and handle that case — don't just silence it and move on.

Mental Model

string = "This will always have a value — I promise, and the compiler will hold me to it."
string? = "This might be nothing — check before you touch it."

Remember:
· NRT is a compiler feature, not a runtime feature — it changes what gets flagged, not what gets executed.
· Warnings, not errors, by default — old code keeps working while you migrate.
· It catches the mistake where it's cheapest to fix: in your editor, not in production.

Key Takeaway


Check Your Understanding

You've seen why null reference exceptions were called a billion-dollar mistake, and how NRT gives the compiler the information it needs to catch them early. Let's check your understanding.

1. With Nullable Reference Types enabled, what does a plain string property (no ?) mean?

Show answer

Correct: B

Why B is correct: A non-nullable string is a promise enforced by compiler warnings, not an unbreakable runtime guarantee. It tells the compiler's flow analysis to flag any code path where the value might end up null.

Why A is incorrect: NRT is compile-time only — there's no runtime enforcement, so a null can still slip in through reflection, external data, or the null-forgiving operator.

Why C is incorrect: int? is a genuinely different runtime type (Nullable<int>). string vs string? compile to the identical IL type — the difference is compiler metadata only.

Why D is incorrect: The ? absolutely changes how the compiler analyzes and warns about the variable — it's not cosmetic, even though it doesn't change the runtime type.

Reinforcement: Non-nullable by default is a compile-time contract backed by warnings, not a runtime guarantee.

2. Why did Tony Hoare call the null reference the "billion-dollar mistake"?

Show answer

Correct: B

Why B is correct: The cost wasn't null itself — it was that type systems gave no signal about which references could be null, so developers routinely wrote code that assumed a value was present, and those assumptions failed in production for decades, across countless languages and systems.

Why A is incorrect: Performance was never the concern — correctness and reliability were.

Why C is incorrect: Memory usage is unrelated to why null was considered a costly design mistake.

Why D is incorrect: Checking for null was always possible (if (x != null)); the problem was that nothing forced or reminded you to do it.

Reinforcement: The billion-dollar mistake is about the absence of a signal, not the existence of null itself.

3. You enable <Nullable>enable</Nullable> in a large, existing project that previously had no nullable annotations. What happens to the build?

Show answer

Correct: B

Why B is correct: Nullable warnings are warnings, not errors, by default. Enabling the nullable context surfaces information about potential null issues without breaking the build, so teams can migrate large codebases incrementally.

Why A is incorrect: Only if you've explicitly configured warnings as errors would this happen — that's not the default behavior.

Why C is incorrect: Existing declarations don't change automatically; the compiler just starts analyzing them under the new rules and reporting mismatches.

Why D is incorrect: NRT does not add any runtime behavior at all — it's purely a compile-time analysis feature.

Reinforcement: This gradual, non-breaking migration path is exactly why NRT was designed as warnings rather than hard errors.

4. Which statement about nullable reference types and nullable value types is accurate?

Show answer

Correct: B

Why B is correct: int? has existed since C# 2 as sugar for the Nullable<T> struct — a genuinely different runtime type carrying a value plus a has-value flag. string?, introduced with NRT in C# 8, is purely compiler-tracked metadata; at the IL level string and string? are identical.

Why A is incorrect: Only int? gets a different runtime type; string? does not.

Why C is incorrect: Both absolutely have effects — int? at runtime via Nullable<T>, and string? at compile time via flow analysis warnings.

Why D is incorrect: This has the two reversed — it's int? that's a real runtime type, and string? that's compile-time only.

Reinforcement: The same ? syntax means two very different things depending on whether it follows a value type or a reference type — don't conflate the two mechanisms.

5. An API layer receives untrusted JSON from an external client and deserializes it into a CustomerDto with a non-nullable Email property. Why is it still a good idea to validate the deserialized object at that boundary, even with NRT enabled?

Show answer

Correct: B

Why B is correct: NRT analyzes the C# code you write; it has no power over data arriving from outside your process. A deserializer can still populate a "non-nullable" property with null if the incoming JSON omits the field, because the runtime type system was never actually enforcing non-nullability — the compiler was just trusting your code's promises.

Why A is incorrect: NRT applies broadly to reference types including string; this isn't an int-specific limitation.

Why C is incorrect: Deserializers vary, but many can and do leave non-nullable properties null if the source JSON is missing that field — the type annotation alone doesn't stop this.

Why D is incorrect: NRT never generates runtime exceptions on its own; it only produces compile-time warnings.

Reinforcement: NRT protects against careless mistakes inside your own code — it is not a substitute for validating data at trust boundaries.

You now understand why nullable reference types exist and how the compiler uses them to catch null bugs before they ever ship. Next up: the day-to-day toolkit for writing null-safe code — ?., ??, ??=, and the null-forgiving operator.


dotnetmadeeasy.com — Learn C# and .NET, the right way.