Nullable reference types are one of the best things to happen to C#. Turn them on, fix the warnings, and most null bugs show up while you type.
Most, not all. Nullable analysis is a compile-time check of your code. It doesn't change what happens at runtime, and it can't see data that comes from outside. Here are five ways null still gets through. In this project, with <Nullable>enable</Nullable>, four of them build with zero warnings. Every output is copied from actually running them on .NET 10.
1. The ! that only silences the compiler
string nickname = null!; // "trust me, it's not null"
Console.WriteLine($"Hi, {nickname.ToUpper()}!");
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
The null-forgiving operator ! tells the compiler to stop warning. That's all it does: no check, no exception at the assignment, nothing at runtime. Every ! is a promise the compiler can't verify.
Fix: if it can be null, say so, and handle it.
string? nickname = null; // honest: it might be null
Console.WriteLine($"Hi, {nickname?.ToUpper() ?? "friend"}!");
Hi, friend!
2. JSON that doesn't read your annotations
public class User
{
public string Email { get; set; } = "";
public string Name { get; set; } = "";
}
var json = """{ "email": "ada@x.dev", "name": null }""";
var user = JsonSerializer.Deserialize<User>(json, JsonSerializerOptions.Web)!;
Console.WriteLine(user.Name.Length);
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
Name is a non-nullable string with a default of "". But the JSON explicitly says null, and by default System.Text.Json sets it, regardless of your annotations. The compiler checked your code; nobody checked the data.
Fix: since .NET 9, you can tell the serializer to enforce the annotations. required also rejects a property that's missing entirely.
public class User
{
public required string Email { get; set; }
public required string Name { get; set; }
}
var options = new JsonSerializerOptions(JsonSerializerOptions.Web)
{
RespectNullableAnnotations = true,
};
var user = JsonSerializer.Deserialize<User>(json, options)!;
Unhandled exception. System.Text.Json.JsonException: The property or field 'name' on type 'T2.Good.User' doesn't allow setting null values. Consider updating its nullability annotation. Path: $.name | LineNumber: 0 | BytePositionInLine: 36.
It still fails, but now it fails at the boundary, with a message that says exactly which field was null, instead of somewhere deep in your code later.
3. Arrays start full of nulls
var names = new string[3]; // string, not string?
names[0] = "Ada";
foreach (var name in names)
Console.WriteLine(name.Length);
3
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
new string[3] creates three slots, and every slot starts as null. The type says string, not string?, and the compiler doesn't track individual array elements, so no warning.
Fix: use a collection that only contains what you actually put in it.
var names = new List<string>(); // only holds what you add
names.Add("Ada");
foreach (var name in names)
Console.WriteLine(name.Length);
3
4. Generic T? that isn't nullable
static T? FindFirst<T>(List<T> items, Func<T, bool> match)
{
foreach (var item in items)
if (match(item)) return item;
return default;
}
var ids = new List<int> { 3, 7, 0 };
Console.WriteLine($"first id == 0: {FindFirst(ids, id => id == 0)}");
Console.WriteLine($"first id > 100: {FindFirst(ids, id => id > 100)}");
first id == 0: 0
first id > 100: 0
"Found 0" and "found nothing" print the same thing. When T is unconstrained and you call it with a value type like int, T? is just int, not int?. default is 0. (You can't even check is null on it; that doesn't compile.)
Fix: return whether you found something separately, with the Try pattern.
static bool TryFindFirst<T>(List<T> items, Func<T, bool> match, out T found)
{
foreach (var item in items)
if (match(item)) { found = item; return true; }
found = default!;
return false;
}
var hasZero = TryFindFirst(ids, id => id == 0, out var zero);
var hasBig = TryFindFirst(ids, id => id > 100, out var big);
Console.WriteLine(hasZero ? $"found {zero}" : "not found");
Console.WriteLine(hasBig ? $"found {big}" : "not found");
found 0
not found
5. The warning everyone scrolls past
This one the compiler does catch:
string? coupon = FindCoupon("WELCOME");
Console.WriteLine(coupon.Length); // the compiler warns here...
warning CS8602: Dereference of a possibly null reference.
But it's a warning. The build succeeds, and at runtime:
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
Fix: make nullable warnings errors, so this can't be merged.
<WarningsAsErrors>nullable</WarningsAsErrors>
error CS8602: Dereference of a possibly null reference.
The build fails.
Recap
-
!silences the compiler; it doesn't prevent null. Treat each one as a code-review question. - Deserializers ignore annotations by default. Use
RespectNullableAnnotationsandrequired. - Arrays of reference types start as nulls. Prefer lists, or fill every slot.
- Unconstrained
T?with a value type is justT. Use the Try pattern. - Make nullable warnings errors.
Which of these have you hit? I'd guess number 2 is the most common in real APIs. Tell me in the comments.
I make short, tested videos about C# bugs that compile fine and still break things. More on the Naze Code YouTube channel.
Sources: Microsoft Learn, Nullable reference types, Respect nullable annotations in System.Text.Json, Required properties, Unconstrained type parameters. Tested on .NET SDK 10.0.302.
Top comments (0)