Null, not null, forgiving null (or maybe not)

Why This Matters When AI Can Just Write the Code

An AI assistant can generate null checks in seconds — so why does understanding null handling matter if you can just ask for it? Because null-related bugs are almost never caught by generating code; they’re caught by reading code and recognising a gap in its reasoning, and that’s a skill no amount of code generation substitutes for.

Here’s a concrete version of the problem: an AI tool asked to fix a compiler warning about a possibly-null reference will very often reach for the null-forgiving operator (!) — it makes the warning disappear, which looks like success. But ! doesn’t add a safety check; it removes one, telling the compiler “trust me, this is never null” without verifying that’s actually true. If the assumption is wrong, the code now fails at runtime with a NullReferenceException, in a place that used to at least warn you at compile time. Whether that ! was a reasonable judgement call or a bug waiting to happen depends entirely on context the tool doesn’t reliably have — and if you don’t understand what ! actually does, you have no way to tell the difference between “the AI just fixed a warning” and “the AI just hid a real bug” by reading the diff.

There’s a second reason this topic specifically rewards genuine understanding: null handling sits at the intersection of the type system and runtime behaviour in a way that’s easy to get subtly wrong even when every individual line looks reasonable. Knowing the difference between a compile-time-only annotation and an actual runtime guarantee — which this post covers in detail — is exactly the kind of judgement that determines whether you trust a piece of generated code or double-check it. That judgement is yours to develop; no tool can hand it to you pre-installed.

Every C# codebase you’ll ever work in has to deal with the possibility that a value is missing, not yet computed, not yet loaded, or intentionally absent. null is the language’s way of representing “nothing is here” — and it is, by a wide margin, one of the most common sources of runtime crashes in real software. This post covers what null actually is at the language and memory level, every mainstream tool C# gives you for checking and guarding against it, the crucial and easily-confused distinction between nullable value types and nullable reference types, the attributes that let library authors describe null behaviour precisely, and the null-forgiving operator — a tool that’s easy to misuse precisely because it looks like a safety feature.

By the end of this post you should be able to explain, precisely, what null represents in memory for a reference type and why the same idea doesn’t apply to a value type; correctly predict, for any given line of code, whether a nullability rule is enforced at compile time, at runtime, or not enforced at all; choose the right null-checking idiom for a given situation and justify the choice; read and apply the nullability attributes ([NotNullWhen], [MaybeNull], and similar) that describe how a method’s nullability behaves; and recognise when the null-forgiving operator is a defensible judgement call versus a bug waiting to happen.

What Null Actually Is, at the Memory Level

To understand null precisely, it helps to think about what a variable actually contains for each kind of type.

For a reference type (a class, for instance), a variable never directly contains the object’s data. It contains a reference — conceptually, an address — pointing to where the actual object lives on the heap. null is simply the special, reserved value meaning “this reference doesn’t point anywhere at all.” No heap object is required to exist for a variable to hold null; the variable is, in a very literal sense, empty.

string name = null;   // name holds no address at all — there is no string object to find
Console.WriteLine(name.Length); // throws NullReferenceException

name.Length fails here because evaluating it requires the runtime to follow the reference stored in name to find an actual string object and read its Length property off of it. But name isn’t pointing at anything, so there’s no object to follow the reference to, and the runtime throws NullReferenceException rather than silently doing nothing or returning a default value. This single mechanism — attempting to dereference an empty reference — is the direct cause of the most common runtime exception in C# (and in every other mainstream language that adopted the same idea from reference-type null).

For a value type (like int or a struct), the story is completely different: the variable is the data, stored directly and inline, with no indirection at all. An int variable is quite literally four bytes holding a number — there’s no separate “int object” living elsewhere that the variable points to. Because there’s no reference involved, there’s nothing that could be “empty” — the variable always holds actual bits representing some value.

int x = null; // compile error CS0037 — there is no concept of "no address" for a value type

This is why the compiler rejects this outright, at compile time, rather than allowing it and failing later — the very idea of null doesn’t apply to how a plain value type is represented in memory. This distinction is the foundation for everything else in this post: Nullable<T> and nullable reference types are two entirely different mechanisms, built to solve the same conceptual problem (“how do I represent ‘no value’ for this type?”) for two categories of type that are represented in memory in fundamentally different ways.

It’s worth a brief historical note here, because it explains why the language ended up with two different mechanisms instead of one unified one: the inventor of the null reference, Tony Hoare, has publicly called it his “billion-dollar mistake” — a feature that seemed convenient to add to a type system in 1965, but which he estimates has since caused decades of subsequent bugs, crashes, and security vulnerabilities across virtually every mainstream language that copied the idea. Nullable<T> (added in C# 2.0) and nullable reference types (added in C# 8.0, over a decade later) represent two separate, successive attempts to make that original mistake less costly — one for value types, one for reference types — without breaking the enormous amount of existing code built on the assumption that any reference could always be null.

Nullable Value Types: Nullable<T> in Full

Since a plain value type can never be null, C# provides a real, concrete wrapper type — Nullable<T> — that adds an explicit “is a value actually present or not” flag alongside the underlying data. You’ll almost always see it written using the shorthand T?:

int? age = null;          // shorthand for Nullable<int> age = null;
Nullable<int> age2 = null; // exactly equivalent, spelled out in full

Nullable<T> is itself a struct, meaning int? remains, fundamentally, a value type — not a disguised reference type. Its actual definition is close to this simplified version:

public struct Nullable<T> where T : struct
{
public bool HasValue { get; }
public T Value { get; } // throws InvalidOperationException if HasValue is false
public T GetValueOrDefault();
public T GetValueOrDefault(T defaultValue);
}

So when you write int? age = null;, nothing about “emptiness” or “missing references” is happening — you’re creating an ordinary struct value whose HasValue field happens to be false. The variable is never “pointing at nothing”; it directly contains a small struct value that represents absence as one of its normal, well-defined states, the same way a bool can represent true or false.

int? age = null;
Console.WriteLine(age.HasValue);           // False
// Console.WriteLine(age.Value);           // throws InvalidOperationException — no value to return
Console.WriteLine(age.GetValueOrDefault()); // 0 — the default value of int, used safely instead of throwing

age = 30;
Console.WriteLine(age.HasValue); // True
Console.WriteLine(age.Value);    // 30

GetValueOrDefault() is often safer to reach for than .Value directly, precisely because it can never throw — it simply falls back to default(T) (or an explicit fallback you supply) when there’s no value present, rather than requiring you to check HasValue first every single time.

Lifted operators

C# does something convenient behind the scenes called “operator lifting”: arithmetic and comparison operators automatically work on Nullable<T> values, propagating null through the calculation, without you having to unwrap .Value manually:

int? a = 5;
int? b = null;

int? sum = a + b; // null — if either operand is null, the result is null
Console.WriteLine(sum.HasValue); // False

bool isGreater = a > b; // False — comparisons involving a null operand are always false, never throw

Notice the last line carefully: a > b doesn’t throw and doesn’t propagate null the way arithmetic does — it evaluates to false, because a comparison against “no value” is defined as never being true. This is a common source of subtle bugs: a > b being false does not mean a <= b is true when either side might be null — both comparisons can independently be false if one side is null, which surprises people expecting comparisons to be perfect opposites of each other the way they are for non-nullable numbers.

Boxing behaviour — a special case worth knowing

Normally, boxing a value type wraps it in an object on the heap, as covered in earlier value-type discussions. Nullable<T> has a special, deliberately designed exception to ordinary boxing rules: boxing a null-valued Nullable<T> produces an actual null reference, not a boxed struct with HasValue == false sitting on the heap.

int? x = null;
object boxed = x;
Console.WriteLine(boxed == null); // True — boxing a null Nullable<T> yields an actual null reference, not a boxed struct

This special-cased behaviour exists specifically so that boxed nullable value types behave the way programmers intuitively expect when compared against null or checked with is null — without it, a great deal of ordinary-looking code checking someObject == null would behave surprisingly for nullable value types passed around as object.

Nullable Reference Types (NRT): A Compile-Time Annotation, Not a Runtime Guarantee

Historically, every reference type in C# could always be null — there was no way to say “this particular string variable should never be null” and have the compiler enforce it. Since C# 8, when a project enables the nullable reference types feature (also called “the nullable context”), you can annotate reference type usages with ? to declare intent:

string name = "Alice";   // intended to always have a value — a "non-nullable" reference type
string? nickname = null; // intended to possibly have no value — a "nullable" reference type

Here is the single most important fact in this entire post, and the one students most reliably get wrong the first time: this ? is purely a compile-time annotation that the compiler uses to generate warnings. It changes absolutely nothing about what happens at runtime. Unlike Nullable<T>, which is a real wrapper struct that genuinely changes the shape of the underlying data, nullable reference type annotations are erased completely by the time your code runs. A string? and a string are the exact same type — System.String — at runtime, compiled to identical IL, with identical behaviour. The only difference between them exists in the compiler’s static analysis while you’re writing the code, not in anything the CLR (Common Language Runtime) knows or checks while your program is actually running.

You can prove this to yourself directly:

string name = null; // triggers compiler WARNING CS8600 — but this still compiles and runs!
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime, exactly as before NRT existed

Nothing stops this from compiling. The nullable reference types feature is, by design, a warning system layered on top of the language — not an enforced runtime guarantee the way Nullable<T> is. This is a deliberate, pragmatic tradeoff: C# has decades of existing code where every reference type could be null, and retroactively making non-nullable the enforced runtime default would have broken an enormous amount of that code the moment anyone upgraded their compiler. Instead, the compiler performs static flow analysis — tracking, as precisely as it can, which variables might be null at each point in your code — and surfaces a warning wherever it believes you might be about to dereference something that could be null, without physically preventing you from doing it anyway.

The nullable context: enabling and controlling NRT

Whether nullable reference type checking is even active is itself controlled, either project-wide (typically via <Nullable>enable</Nullable> in your .csproj file) or file-by-file, or even line-by-line, with a compiler directive:

#nullable enable
string? maybeNull = null; // annotations are meaningful here — warnings apply

#nullable disable
string alsoNull = null;   // in a disabled context, no warning at all — this is the pre-C#-8 world

#nullable restore          // returns to whatever the surrounding project-level setting was

Code written before nullable reference types existed, or code in a project that hasn’t opted in, is said to be in an “oblivious” context — the compiler doesn’t apply nullable warnings to it at all, and every reference type behaves exactly as it always did (implicitly nullable, no annotations, no warnings). This matters practically: if you’re calling into an older library that hasn’t been annotated for nullability, the compiler generally can’t warn you about null risks flowing out of that library’s methods, even in your own fully nullable-enabled code, because the library itself never declared its intentions.

Generic type parameters and NRT

Nullability annotations interact with generics in a way that’s worth flagging explicitly, since it trips people up:

class Box<T>
{
public T Value; // is this nullable or not? It depends entirely on what T ends up being!
}

Box<string> b1 = new Box<string> { Value = null };
Box<int> b2 = new Box<int> { Value = null };

The compiler has to reason about nullability generically here, because T might end up being a reference type or a value type depending on how Box<T> gets used — this is a genuinely more advanced corner of the feature, and library authors writing generic code often need extra annotations (like T? combined with constraints, or the [MaybeNull]/[AllowNull] attributes covered later) to precisely describe nullability that depends on the type parameter.

Advisory, not enforced

This all has a very practical consequence worth internalising: nullable reference type warnings are advisory, not enforced, by default. If your team ignores compiler warnings, or your build pipeline doesn’t treat warnings as errors, string? provides essentially no actual protection at all — it becomes documentation of intent that nothing is actually forcing anyone to respect. Many professional teams configure their build to treat nullable-related warnings as build-breaking errors (<WarningsAsErrors>Nullable</WarningsAsErrors> or similar) specifically to close this gap and make the annotations behave more like a genuine guarantee — which is worth knowing exists as an option even though it isn’t the default.

Checking for Null

C# provides several distinct ways to check whether something is null, and they aren’t fully interchangeable — the differences matter, sometimes a great deal.

== null vs. is null

if (name == null) { ... }
if (name is null) { ... }

Both usually produce the same result, but is null is the generally preferred idiom, for a specific and important reason: == is an operator, and operators can be overloaded by the type being compared. If the type of name overloads == with custom logic — records do exactly this automatically, since they generate their own == for value equality — then name == null invokes that custom operator rather than a guaranteed, unambiguous null check. A poorly written custom == overload could, in principle, behave unexpectedly when one side is null (for example, throwing instead of returning false, if the override doesn’t defensively handle a null argument). is null, by contrast, is a pattern match evaluated directly by the compiler using the runtime’s built-in notion of “this reference is empty” — it cannot be overridden, intercepted, or fooled by any type’s custom operator, so it always performs a true, unambiguous null check no matter what type you’re checking, which is exactly the guarantee you want from a null check.

The null-conditional operator: ?.

Rather than writing a nested chain of null checks by hand, ?. lets you safely navigate through a chain of references that might be null at any step, automatically short-circuiting to null the moment it encounters one:

// Without null-conditional:
string city = null;
if (customer != null && customer.Address != null)
city = customer.Address.City;

// With null-conditional — equivalent, far more concise:
string city = customer?.Address?.City;

If customer is null, the entire expression short-circuits immediately and evaluates to null — it never even attempts to access .Address, avoiding the exception a plain customer.Address.City would throw. You can also use it to conditionally invoke a method or event, calling it only if the reference isn’t null and otherwise doing nothing at all:

onCompleted?.Invoke(); // calls Invoke() only if onCompleted isn't null; silently does nothing otherwise

?. also combines with indexers and method calls in a chain, short-circuiting the moment any link is null:

int? firstItemLength = list?[0]?.Length; // safely handles list being null, or list[0] being null

Null-coalescing operators: ?? and ??=

?? lets you supply a fallback value to use when the left-hand side turns out to be null, and it chains naturally with ?.:\

string city = customer?.Address?.City ?? "Unknown";
// If the whole chain above evaluates to null at any point, "Unknown" is used instead

??= is a compound assignment version: it assigns the right-hand side only if the variable currently holds null, leaving it completely unchanged otherwise — useful for lazy initialisation and simple caching patterns:

string? cachedResult = null;
cachedResult ??= ComputeExpensiveResult(); // only computes and assigns if cachedResult was null
cachedResult ??= ComputeExpensiveResult(); // this second call never even executes ComputeExpensiveResult again

Pattern matching against null

Modern C# lets you use is/is not directly as readable null checks, and switch expressions can match null explicitly as one of several patterns, putting the null case on equal footing with every other case being matched rather than treating it as a special pre-check:

if (name is not null)
Console.WriteLine(name.Length);

string Describe(object? value) => value switch
{
null => "nothing here",
int n when n < 0 => $"a negative int: {n}",
int n => $"an int: {n}",
string s => $"a string: {s}",
_ => "something else"
};

The Try-pattern as an alternative to nullable returns

A very common and idiomatic way to sidestep null-checking altogether for lookups that might fail is the “Try” pattern, seen throughout the .NET standard library (Dictionary<TKey,TValue>.TryGetValue, int.TryParse, and many others):

Dictionary<string, int> ages = new() { ["Alice"] = 30 };

if (ages.TryGetValue("Bob", out int age))
Console.WriteLine(age);
else
Console.WriteLine("Not found");

Rather than returning a nullable value type and forcing every caller to check for null (or, worse, returning a sentinel value like -1 that’s easy to forget to check for), the method returns a bool indicating success and delivers the actual value through an out parameter only when it succeeded. This is often considered a cleaner API design than nullable returns for exactly this kind of “might not find anything” scenario, because the success/failure check and the value retrieval happen in a single, hard-to-misuse expression — you cannot accidentally use age without having gone through the success check first, since it isn’t definitely assigned otherwise.

The Null-Forgiving Operator: !

The null-forgiving operator is a single exclamation mark placed after an expression, and it exists specifically to interact with the compile-time warning system:

string? maybeName = GetNameOrNull();
string name = maybeName!; // "trust me, compiler — this won't actually be null"

Here is the fact students most often assume incorrectly, so it’s worth stating as plainly as possible: the ! operator does absolutely nothing at runtime. It performs no check, adds no safety, and throws no exception if the value genuinely turns out to be null. Its only effect is telling the compiler’s nullable-warning analysis “stop warning me about this specific expression — I know something you don’t.” If you’re wrong, and the value actually is null, the code compiles cleanly with no warning at all, and then throws a NullReferenceException at runtime exactly as it would have without the ! — except now there was no compiler warning to have caught the mistake ahead of time, which arguably makes the situation worse than not using NRT at all, since it creates false confidence.

string? maybeName = null;
string name = maybeName!; // compiles with zero warnings
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime — the ! changed nothing here

This makes ! a genuinely double-edged tool, and it’s worth being explicit about when each side applies.

It’s a legitimate, reasonable choice when you have information the compiler’s flow analysis genuinely can’t see. A common example is right after a manual null check performed inside a separate helper method, which the compiler generally can’t trace through automatically:

void EnsureNotNull(string? value)
{
if (value is null) throw new ArgumentException();
}

string? input = GetInput();
EnsureNotNull(input);
string result = input!; // input can't actually be null here, but the compiler can't see that through the helper call

(Later we’ll cover [NotNull] and related attributes, which let you tell the compiler about exactly this kind of pattern without needing ! at all — a more precise, more scalable solution than sprinkling ! throughout your codebase.)

It’s a code smell — often papering over a real bug — when it’s used reflexively just to silence a warning without actually verifying the assumption behind it. If you find yourself typing ! purely because the compiler is complaining and you want the complaint to stop, that’s exactly the situation where you’re most likely to be quietly disabling a warning that was correctly flagging a genuine risk. A good habit: every time you write !, be able to state in one sentence why you’re certain the value can’t be null at that specific point — if you can’t articulate that reason, it’s a strong signal to add an actual check instead of suppressing the warning.

! used on more than just simple variables

The null-forgiving operator can be applied to any expression, not just a bare variable, which is worth knowing so you can recognise it in the wild:

var firstAdult = people.FirstOrDefault(p => p.Age >= 18)!; // asserts the result won't be null, suppressing the warning

This particular example is a common trap: FirstOrDefault returns null when no element matches the predicate, so asserting the result away with ! here is only safe if you’ve independently verified, through other logic, that a match is guaranteed to exist — otherwise you’ve silenced a warning that was correctly describing a real possibility.

Nullability Attributes: Precisely Describing a Method’s Null Behavior

Sometimes a method’s nullability can’t be fully captured by a simple ? or its absence — the actual behavior depends on what happens when the method runs, not just its static signature. C# provides a small set of attributes, primarily used by library authors, that let you describe this precisely so the compiler’s flow analysis can follow along correctly at every call site, without callers needing ! at all.

[NotNullWhen(bool)] — describes an out parameter (or return value) that is guaranteed non-null specifically when the method returns a particular bool value. This is exactly how TryGetValue-style methods are annotated in the standard library:

bool TryGetName(int id, [NotNullWhen(true)] out string? name)
{
if (id == 1) { name = "Alice"; return true; }
name = null;
return false;
}

if (TryGetName(1, out string? result))
Console.WriteLine(result.Length); // no warning here — the compiler trusts the attribute's promise

Without this attribute, the compiler would still warn about result.Length, since result’s declared type is string?. The attribute tells the compiler, precisely, “whenever this method returns true, treat that out parameter as non-null from that point forward” — letting the compiler’s flow analysis stay accurate without any ! needed anywhere at the call site.

[MaybeNull] — the opposite kind of promise: marks something whose declared type looks non-nullable, but which might actually return null at runtime anyway (often used for backward compatibility with older, unannotated APIs, or generic code where T might turn out to be a reference type).

[NotNull] — asserts that a parameter or return value will never be null, even though its declared type technically allows it (string?) — commonly used on out/ref parameters that a method always assigns a real value to before returning, regardless of input.

[AllowNull] / [DisallowNull] — used on parameters to describe input nullability independently of a property’s own get/set nullability — for example, a property whose setter accepts null (which then gets normalised to an empty string internally) but whose getter never returns null.

[DoesNotReturn] — marks a method that never returns normally (it always throws), which lets the compiler correctly treat any code after a call to it as unreachable — useful for custom guard-clause helper methods:

[DoesNotReturn]
void ThrowIfInvalid(string? input)
{
if (input is null) throw new ArgumentNullException(nameof(input));
throw new InvalidOperationException(); // this method always throws, one way or another
}

You won’t need most of these day-to-day as an application developer — but recognizing them when you see them in .NET’s own source, or in a well-annotated third-party library, is what lets you understand why the compiler correctly avoids warning you in some cases and correctly does warn you in others, rather than it feeling arbitrary.

Guard Clauses and Trusting the Type System

In real code, you’ll constantly face a judgement call: should you defensively check every argument for null at the top of every method, or trust that the type system (via nullable reference type annotations) has already ruled null out?

A common, concise pattern for explicit, defensive checks at the boundaries of your code — especially public APIs where callers might not respect your nullable annotations, might be calling from an oblivious (pre-NRT) context, or might be passing data that came from outside your program’s control entirely (deserialised JSON, user input, a database row) — is:

public void ProcessOrder(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
// from this point on, customer is guaranteed non-null, with a clear, immediate exception if it wasn't
Console.WriteLine(customer.Name);
}

ArgumentNullException.ThrowIfNull is a concise, standard-library way to fail fast and loudly, with a clear exception message naming exactly which argument was the problem — far more useful for debugging than an anonymous NullReferenceException thrown from somewhere deep inside the method body later on, potentially far removed from where the actual bad value entered your code.

The broader judgement call — defend everywhere vs. trust the type system — doesn’t have one universally correct answer, but a reasonable default is: check defensively at the boundaries of your code (public APIs, deserialised data, anything coming from outside your own program’s control), and trust your non-nullable annotations internally, once you’re confident your codebase takes nullable warnings seriously (ideally with warnings-as-errors enabled). Checking null obsessively on every single private method call, when the type system has already given you strong non-nullable guarantees internally, adds clutter without meaningfully reducing risk — but skipping checks entirely at your public boundaries, where you don’t control what callers pass in, removes a safety net you likely still need.

Required Properties: A Different Tool for “This Must Be Set”

Nullable reference type warnings are about whether a reference might be null. A related but genuinely distinct problem is object initialisation: how do you guarantee a property gets a value at all, without necessarily making it nullable? Since C# 11, the required modifier addresses this directly:

class Customer
{
public required string Name { get; init; }
public string? Nickname { get; init; } // genuinely optional
}

var c = new Customer { Nickname = "Al" }; // compile error! Name is required and wasn't set
var c2 = new Customer { Name = "Alice" }; // fine — Name provided, Nickname left null

This is a compile-time-enforced guarantee that a property must be explicitly set during construction, which is a stronger and more precise tool than simply leaving a property non-nullable and hoping every constructor path sets it — required makes the compiler actively check every object-creation site for a missing assignment, rather than relying on nullable-warning flow analysis to eventually notice a gap, which it might not always catch depending on how the object gets constructed.

null vs. default — A Related but Distinct Idea

It’s worth clearly separating null from a closely related but different concept: default(T), or its shorthand default. default means “the zero-initialised value for type T” — for reference types, that value happens to be null, but for value types, it’s a real, valid value (0 for int, false for bool, all-zero fields for a struct), not an absence of anything.

string s = default; // null — the default for any reference type is null
int i = default; // 0 — a completely valid, ordinary int value, not an absence of a value
Point p = default; // a Point with all fields zeroed out, if Point is a struct — still a real Point

This distinction matters because it’s easy to conflate “no value was provided” with “the default value was provided,” and they aren’t the same thing for value types: a Point that’s default is a perfectly real, usable Point sitting at the origin — it’s not “missing” the way Nullable<Point> being null would be. If you need to distinguish “a real, valid zero value was explicitly provided” from “no value was provided at all” for a value type, default alone cannot do that — you need Nullable<T> (or T?) specifically, because only Nullable<T> carries an explicit HasValue flag separate from the value itself.

Null in Collections: A Design Convention Worth Adopting

One more practical distinction worth internalising, since it comes up constantly in real code: an empty collection and a null collection mean two different things, and conflating them is a common source of bugs and defensive-code clutter.

List<string> tags = new(); // an empty list — "this customer has zero tags," a known, valid state
List<string>? tags2 = null; // no list at all — "we don't know this customer's tags," a fundamentally different state

A very widely adopted convention in professional C# code is: methods that return collections should return an empty collection instead of null, whenever there’s a meaningful “nothing here” case, specifically so every caller can safely foreach over the result without needing a null check first:

// Preferred:
public List<string> GetTags(Customer customer) => customer.Tags ?? new List<string>();

// Avoid, if at all possible:
public List<string>? GetTags(Customer customer) => customer.Tags; // forces every caller to null-check before iterating

This convention doesn’t apply universally — sometimes “we don’t know” genuinely needs to be distinguished from “we know there are zero” — but as a default, it eliminates an enormous number of unnecessary null checks scattered throughout a codebase, and it’s worth adopting deliberately rather than by accident.

Summary

null represents an empty reference — literally, a variable holding no address at all — which is why the concept applies naturally to reference types (which hold an address to data stored elsewhere) but not to plain value types (which hold their data directly, with no address to leave empty). Nullable<T> gives value types a real, runtime opt-in mechanism for representing absence anyway, implemented as an actual struct carrying a genuine HasValue flag, complete with lifted operators and a special boxing rule that makes a null Nullable<T> box to an actual null reference. Nullable reference type annotations (string?) work in a completely different way: they’re a compile-time-only warning system, controlled by the nullable context, with zero effect on runtime behaviour — a string can still be assigned null and still throws a NullReferenceException exactly as before, and the annotation only changes what the compiler warns about while you’re writing the code, not what’s enforced when it runs, unless your build explicitly treats those warnings as errors. For checking null, prefer is null over == null since it can’t be fooled by a type’s overloaded == operator, lean on ?./??/??= and pattern matching for concise handling, and reach for the Try-pattern over nullable returns when a lookup might reasonably fail. The null-forgiving operator ! does nothing at runtime — it only silences a compiler warning — so treat it as a deliberate, justifiable assertion, not a routine fix, and prefer the [NotNullWhen]/[NotNull]-style attributes when you’re the one writing an API whose nullability depends on its behaviour rather than its static signature. Keep default conceptually separate from null (a value type’s default is a real, valid value, not an absence of one), default to returning empty collections rather than null ones wherever “nothing here” is a meaningful, expected state, defend explicitly against null at the boundaries of your code where you don’t control incoming data, and trust your non-nullable annotations internally once your team takes warnings seriously.

Leave a Reply