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.
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.
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.
nullstring and string? mean the same thingNullReferenceExceptionsstring means "never null" — enforced by the compiler's warningsstring? means "can be null" — you must check firstBefore 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:
string GetCity(Customer customer) tells you nothing about whether customer can be null, or whether the returned city might be null. You had to read the implementation — or the documentation, if you were lucky — to find out.customer.Address.City where Address is null) compiled fine and only failed when that exact code path actually ran with a null value — often in production, on a path your tests never exercised.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.
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.
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:
string as non-nullable — assigning null to it produces a warning.string? as nullable — and requires you to check for null (or otherwise prove it isn't null) before dereferencing it.if (customer.Address != null), the compiler remembers that inside that block, customer.Address is "known not-null," and stops warning about it there.<WarningsAsErrors>.Here's the shift in where a null problem gets caught:
// 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
public class Customer
{
public string Name { get; set; } // never null — required
public string? MiddleName { get; set; } // optional — may be null
}
string promises the compiler this will always have a value.string? tells the compiler and every caller: "check me first."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
}
}
if-check, a ?. access, or an early return all "narrow" a variable to non-null for the code that follows.CS8602: Dereference of a possibly null reference).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());
}
#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."
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.
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.
string and a string? compile down to the exact same IL type — there is no separate runtime representation, unlike int? which really is a different type (Nullable<int>) under the hood.NullableAttribute / NullableContextAttribute metadata into the compiled assembly, so other projects (and tools like the compiler itself, when consuming your library) can still see your nullability annotations and warn their own callers.null to a non-nullable string by working around the type system — via reflection, deserialization, the null-forgiving operator (!), or code compiled without nullable context — and the program will happily run until something actually dereferences it, producing the exact same NullReferenceException as always.NullReferenceException impossible.?, different mechanismint? 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.
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.
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.
! instead of fixing the real issueSuppressing 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).
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.
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.
#nullable disable at the top of files you haven't migrated yet, and remove that line as you clean each one up).string?) or not (string), inside a nullable-enabled context.NullReferenceException impossible.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?
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"?
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?
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?
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?
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.