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 herewith 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 errorWhat 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 = errorCS8597: 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; // unboxingIf 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; // CS8625or 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.