Going All-In on Nullability

Since Microsoft introduced the nullability concept in C# 8, we have an entire new possibility of omitting null checks with confidence - but only if we set up compiler errors the right way. In this text, we'll explore what this nullability concept is, why we need it and how to enforce it.

Let's start with a quick look into what it actually is. Before C# 8, you might have written code like this

var person = _repository.GetPerson(id);
if(person == null)
    throw new ArgumentException($"Person with Id {id} does not exist");
// rest of logic here

with GetPerson having a signature like public Person GetPerson(int id).
The method you're calling here might or might not return null. You wouldn't be able to decern this by its signature alone, which in the end could mean you're doing unnecessary null checks in case the method will never return null.
All this is based on the concept of reference types being able to contain null as their value, whereas value types cannot. The latter have to explicitly be declared as being able to contain null.

int number = null; // compiler error
int? number = null; // no compiler error

What the nullability feature now does, is to (somewhat) enable the same concept for reference types. You'll be able to declare a method signature like public Person? GetPerson(int id) and make it clear to the caller that it is absolutely possible that null is returned. However, you can also keep the previous signature of public Person GetPerson(int id) and signal the opposite: this method will not return null, you don't need null checks for the return value.
This means that the nullability features lets the programmer describe intent implicitly in code better than before where they might have to mention such thing in the method's summary.

Sounds good, but if you only enable nullability without paying much more attention to it, you're going to shoot yourself in the foot sooner or later. And that's because of one core problem in how the compiler handles this feature - especially in contrast to value type nullability.
As mentioned above int number = null will results in a compilation error and when you turn on nullability, you'd expect that Person person = null will also. But it won't. The compiler will - by default - just give you a warning à la "be careful here, bro, this does not look right", but it will compile successfully nonetheless. Same goes for the following

public Person GetPerson(int id)
{
    return null;
}

This will compile perfectly (with a similar warning), but violates the idea of nullability. Of course, no one's doing this on purpose, but in a method of 20+ lines, such things can happen and slip by.

Teaching the compiler about errors

Now, how do we fix it? We somehow have to tell the compiler to not regard these things as warnings, but errors. Unfortunately, there's not a singular switch you can flip and everything's great. You have to configure each singular possible type of error. The easiest way to do this is in an .editorconfig - also to ensure that all your fellow contributors and the CI pipeline stick to the same rules.

I'll now list all the .editorconfig rules you need to add and will then go through them one by one and display in which scenario it is forcing a compilation error.

# CS8597: Thrown value may be null.
dotnet_diagnostic.CS8597.severity = error
CS8600: Converting null literal or possible null value to non-nullable type.
dotnet_diagnostic.CS8600.severity = error
CS8601: Possible null reference assignment.
dotnet_diagnostic.CS8601.severity = error
CS8602: Dereference of a possibly null reference.
dotnet_diagnostic.CS8602.severity = error
CS8603: Possible null reference return.
dotnet_diagnostic.CS8603.severity = error
CS8604: Possible null reference argument for parameter.
dotnet_diagnostic.CS8604.severity = error
CS8605: Unboxing a possibly null value.
dotnet_diagnostic.CS8605.severity = error
CS8618: Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring as nullable.
dotnet_diagnostic.CS8618.severity = error
CS8625: Cannot convert null literal to non-nullable reference type.
dotnet_diagnostic.CS8625.severity = error
CS8629: Nullable value type may be null.
dotnet_diagnostic.CS8629.severity = error

CS8597: Thrown value may be null

At first glance, this one has not so much to do with the nullability feature, but actually it does. The simplest line to provoke this error with is by only doing throw null. You might argue that this is pretty silly and no one will ever do that. Sure. But how about this one here?

Exception? ex = null;
// more code that might or might not set ex
throw ex;

This will now also not compile with a CS8597 error and is a lot harder to spot than a simple throw null.

CS8600: Converting null literal or possible null value to non-nullable type

Arguably the simplest and one of the most common error types. Assigning null to a not nullable type, e.g. Person person = null.

CS8601: Possible null reference assignment

Here we're e.g. talking about code where at compile time, it's not possible to say whether we'll assign null during runtime or not. Take this method signature for example

public void DoSomething<T>(T id = default)
{
    // implementation
}

The default assignment is the problem here. If we call DoSomething with int as the generic type, everything's fine. default(int) is 0 and, thus, not null. However, if we call it with Person as generic type, we'd be assigning null to the not nullable parameter id, as default of reference types is always null.

CS8602: Dereference of a possibly null reference

Also a classic. You might get this one when calling e.g. a property or a method on an object that might be null.

Person? person = GetCurrentUser();
var name = person.Name;

GetCurrentUser might return null and calling Name over null would result in a NullReferenceException.

CS8603: Possible null reference return

We've seen this one before, when you e.g. directly return null or a nullable variable when the return type is not nullable

public Person GetPerson(int id)
{
    return null;
}
public Person GetPerson(string name)
{
    Person? foundPerson = null;
    // implementation
    return foundPerson;
}

CS8604: Possible null reference argument for parameter

The name already says it - you might be passing null as an argument into a method that does not allow null as parameter.

Person? person = GetCurrentUser();
Logout(person);
public void Logout(Person person)
{
    // implementation
}

CS8605: Unboxing a possibly null value

Similar to CS8600, you'll get this error in case you're for example trying to unbox a value type from a nullable reference type

int? i = null;
object? o = i; // boxing
var x = (int)o; // unboxing

If you do this with reference types, you'll get a CS8600.

CS8618: Non-nullable variable must contain a non-null value when exiting constructor

A classic "oh, I forgot that". You do not assign a value to a non nullable field or property before exiting the constructor.

public class ApplicationState
{
    private Person _currentUser;
    public ApplicationState(){} // _currentUser is not assigned although it's not nullable
}

CS8625: Cannot convert null literal to non-nullable reference type

This one is a bit harder to provoke, but occurs when you don't have a nullable accepting overload of an operator, e.g.

public class Person
{
    // other code
    public static bool operator ==(Person left, Person right)
    {
        // implementation
    }
}
Person person = new("Peter");
var isNull = person == null; // CS8625

or when trying to assign null to a not nullable out or ref parameter

public void Login(out Person user)
{
    user = null;
}

CS8629: Nullable value type may be null

Not really related to the nullability feature directly (but equally important), seen when trying to get the value out of a nullable value type

int? i = GetNumber();
var x = i.Value;

Limitations

But even with all these rules set up, there are a couple of pitfalls.
The compiler is smart enough to detect when e.g. a variable is definitely not null and will not show any error

Person? person = FindPerson();
if (person is null)
    throw new Exception();
// this can only be reached if person is not null
// which makes it safe to call .Name on it
var name = person.Name;

However, sometimes this kind of logic is hidden in sub-methods or too complex for the compiler to understand. That's where the null forgiving operator ! comes into play, which is the kind of magic joker that tells the compiler that you made sure that the previous part is not null and it should treat it that way. But in the same way, it can be abused to just get rid of the error without actually making sure that null is impossible at this point.
This enables hilarious statements like Person person = null! which compiles perfectly and basically tells the compiler "I assure you, this null literal is not null". It simply turns the compiler into a small toddler and makes it believe stuff regardless whether it's true or not.

Another big limitation you have to keep in mind is that all the nullability features will only be enforced for assemblies that have the nullability feature enabled. If you call some kind of .net standard library, you still have to do null checks on your own.

Conclusion

The nullability features was a big milestone in regard to null-safe programming, but you have to enforce its rules through the compiler. Otherwise, the mere warnings are overlooked too easily between all the 200 others that you probably have. It reduces the amount of code written, improves the confidence in it & conveys semantic intent better and faster.
Converting large code bases to it might be a hard or even impossible task, but new projects should embrace this feature from the start on.