How C# Got Here.
Sir Tony Hoare, who died in March 2026 at the age of 92, gave programming Quicksort, Hoare logic and Communicating Sequential Processes. He also gave it the null reference, and later apologised for it in public.
In a 2009 talk at QCon London, Hoare explained that he had put null references into ALGOL W in 1965 “simply because it was so easy to implement”. He called the decision “my billion-dollar mistake”. The figure was an order-of-magnitude guess at the cost in crashes, bugs and security holes: somewhere between a hundred million and ten billion dollars.
C# inherited null from the languages before it, and it can never take it away. Too much code depends on it. What it has done instead, over twenty years, is pay the debt down in instalments. Following those payments turns out to be a good way to see how the language itself has changed.
This is the first post in a series that follows one problem at a time through C#’s history. Instead of listing what each version added, each post traces how the language answered one kind of pain, and how each answer set up the next.
We’ll follow one line of code through the whole story:
string city = order.Customer.Address.City;
Three dots, three chances to throw. And if none of them does, City itself can still be null, waiting to blow up somewhere else.
Act one: living with it (C# 1 and 2)
In C# 1.0 (2002), any reference could be null. The compiler didn’t know which ones were supposed to be, and neither did you, unless someone had left a comment. So you checked everything:
public string GetCity(Order order)
{
if (order != null &&
order.Customer != null &&
order.Customer.Address != null &&
order.Customer.Address.City != null)
{
return order.Customer.Address.City;
}
return "Unknown";
}
That was the tidy version. The untidy one had four nested ifs.
Value types had the opposite problem. An int or a DateTime could never be null, even when “no value” was exactly what you meant. So we invented sentinels, like -1 for “unknown” or DateTime.MinValue for “never”, and hoped every caller knew the convention. Databases had real nulls, so ADO.NET represented them with yet another stand-in, DBNull.Value.
C# 2.0 (2005) fixed the value-type half. Nullable<T>, written int?, made “no value” part of the type, and the null-coalescing operator ??, which works on references too, gave it a one-line fallback:
int? discount = GetDiscount(); // null means "no discount", and the type says so int applied = discount ?? 0;
This is easy to underrate. For the first time, the type told you whether null was possible: an int couldn’t be null, an int? could. That idea is the thread the rest of this story follows. For reference types, though, it would take another fourteen years.

Act two: less typing (C# 6 and 7)
For a decade after that, null handling barely changed. Then C# 6 (2015), the first version built on the Roslyn compiler, added the null-conditional operator. If the left side of ?. is null, the whole expression evaluates to null instead of throwing:
public string GetCity(Order order) =>
order?.Customer?.Address?.City ?? "Unknown";
A dozen lines became one expression. There’s an indexer version too: orders?[0] is null when orders is.
A less famous win was raising events. Another thread could unsubscribe between your null check and the call, so the safe pattern needed a local copy:
var handler = PropertyChanged; // copy first, in case another thread unsubscribes
if (handler != null)
{
handler(this, new PropertyChangedEventArgs("Name"));
}
With ?., the delegate is read exactly once, so the same protection comes built in:
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
C# 7 (2017) shortened the checks you still had to write. Throw expressions folded a guard clause into the assignment:
public Customer(string name)
{
_name = name ?? throw new ArgumentNullException(nameof(name));
}
Pattern matching added is null. Unlike == null, it always tests the reference itself, so a class that overloads == can’t fool it. C# 9 later added is not null, which reads a lot better than the !(x is null) people wrote in the meantime.
All of this made null checks shorter. None of it told you where to put them. Every reference could still be null, and the only way to find out which ones really were was to read the code, or wait for production to tell you.

Act three: the compiler joins in (C# 8 onwards)
C# 8 (2019) finally applied the C# 2 idea to reference types. With nullable reference types switched on, string means “not null” and string? means “might be null”, and the compiler tracks which is which as values move through your code.
Hoare had pointed in this direction himself. In that same 2009 talk, he named Spec#, a Microsoft Research extension of C#, among the languages starting to check for non-null references at compile time. C# itself got there ten years later.
Here’s our model with its nullability written down:
public class Order { public Customer? Customer { get; set; } }
public class Customer { public Address? Address { get; set; } }
public class Address { public string? City { get; set; } }
Write the original line against it, and the compiler objects three times:
public string GetCity(Order order) =>
order.Customer.Address.City;
// warning CS8602: Dereference of a possibly null reference. (Customer)
// warning CS8602: Dereference of a possibly null reference. (Address)
// warning CS8603: Possible null reference return. (City)
The fix is the C# 6 version, minus one check:
public string GetCity(Order order) =>
order.Customer?.Address?.City ?? "Unknown";
The ?. after order is gone. The parameter is declared Order, not Order?, so the method is entitled to assume it isn’t null, and a caller who tries to pass null gets the warning instead. The types now say where null is allowed, and the compiler checks every use against them. More than fifty years after ALGOL W, “can this be null?” finally has an answer written in the code.
Since .NET 6 (2021), new project templates switch nullable reference types on by default, and the .NET libraries themselves are annotated, so calls into the framework get checked too. The rest of the modern toolkit handles the edges:
??=(C# 8) assigns only when the target is null:_cache ??= LoadCache();- The null-forgiving operator
!(C# 8) tells the compiler “trust me”. It silences a warning without adding a check, so every!is a place where the compiler can no longer protect you. required(C# 11) answers CS8618, the warning you get when a non-nullable property might still be null after the constructor finishes. The warning text itself now suggests it. Instead of writing a constructor,public required string Name { get; init; }makes whoever creates the object set it in the object initializer, or the code doesn’t compile.- Null-conditional assignment (C# 14) puts
?.on the left of=.customer?.Order = GetCurrentOrder();assigns only whencustomerisn’t null, and when it is, the right-hand side isn’t even evaluated.
The one that got away
Not every instalment was accepted. Early C# 11 previews in 2022 included parameter null checking: put !! after a parameter name, and the compiler writes the ArgumentNullException for you.
public Customer(string name!!) // never shipped
{
_name = name;
}
The pushback was loud, much of it about the syntax, and in April 2022 the language design team pulled the feature from C# 11. Their meeting notes are unusually candid. Most reactions had actually been positive, but not by the large margin they want for a feature this widely used, and the design team itself was split.
What you write instead arrived with .NET 6: ArgumentNullException.ThrowIfNull(name);. It fills in the parameter name on its own, thanks to C# 10’s [CallerArgumentExpression] attribute, so you don’t even need nameof.
The verdict
Is the debt paid off? No, and it can’t be.
Nullable reference types are arguably the most important safety feature C# has ever added. They turn “can this be null?” from tribal knowledge into something written in the type. But they are warnings, not guarantees, and the runtime doesn’t enforce them. A JSON deserializer, reflection, a library compiled without annotations or one careless ! can put null exactly where the type says it can’t be. Even the compiler’s own analysis has blind spots: new string[3] is an array of non-nullable strings, and all three of them are null. Public APIs still need ThrowIfNull.
The migration is where it gets real. Switching nullable on in an established codebase can light up hundreds of warnings on the first build. The sensible route is gradual, with #nullable enable file by file and new code first, and Microsoft’s migration guide covers the options.
And the older layers haven’t gone anywhere. Projects targeting .NET Framework still default to C# 7.3, so a lot of enterprise code still lives in act two, unless someone has raised the language version.
Three versions of one line
The line we started with has been rewritten three times:
- C# 1: a dozen lines of guard clauses you had to remember to write.
- C# 6: one expression, which you still had to remember to write.
- C# 8: almost the same expression, and the compiler reminds you when you forget.
The last step barely changed the syntax. What changed is that the compiler finally knows what you meant.
One postscript. In that same talk, Hoare said he had known of a safer design back in 1965: a disjoint union, in which a reference is either a real object or an explicit “nothing”, and the program has to check which before using it. C# 15, due in November 2026, adds union types. They won’t retire null, and nothing will. But sixty-one years later, the idea Hoare passed over is finally part of the language.
At a glance
| Version | Year | Added |
|---|---|---|
| C# 2 | 2005 | int? (Nullable<T>) and ?? |
| C# 6 | 2015 | ?. and ?[] |
| C# 7 | 2017 | ?? throw and is null |
| C# 8 | 2019 | Nullable reference types, ??= and ! |
| C# 9 | 2020 | is not null |
| C# 10 | 2021 | [CallerArgumentExpression], which ThrowIfNull relies on |
| C# 11 | 2022 | required members (and !! was pulled) |
| C# 14 | 2025 | Null-conditional assignment |
Written with AI assistance.
no faces.
Comments