DEV Community

Cover image for 5 ways null gets past C# nullable reference types
Naze Code
Naze Code

Posted on

5 ways null gets past C# nullable reference types

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()}!");
Enter fullscreen mode Exit fullscreen mode
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
Enter fullscreen mode Exit fullscreen mode

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"}!");
Enter fullscreen mode Exit fullscreen mode
Hi, friend!
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
Enter fullscreen mode Exit fullscreen mode

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)!;
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
3
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
3
Enter fullscreen mode Exit fullscreen mode

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)}");
Enter fullscreen mode Exit fullscreen mode
first id == 0:  0
first id > 100: 0
Enter fullscreen mode Exit fullscreen mode

"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");
Enter fullscreen mode Exit fullscreen mode
found 0
not found
Enter fullscreen mode Exit fullscreen mode

5. The warning everyone scrolls past

This one the compiler does catch:

string? coupon = FindCoupon("WELCOME");

Console.WriteLine(coupon.Length);  // the compiler warns here...
Enter fullscreen mode Exit fullscreen mode
warning CS8602: Dereference of a possibly null reference.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Fix: make nullable warnings errors, so this can't be merged.

<WarningsAsErrors>nullable</WarningsAsErrors>
Enter fullscreen mode Exit fullscreen mode
error CS8602: Dereference of a possibly null reference.
Enter fullscreen mode Exit fullscreen mode

The build fails.

Recap

  1. ! silences the compiler; it doesn't prevent null. Treat each one as a code-review question.
  2. Deserializers ignore annotations by default. Use RespectNullableAnnotations and required.
  3. Arrays of reference types start as nulls. Prefer lists, or fill every slot.
  4. Unconstrained T? with a value type is just T. Use the Try pattern.
  5. 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)