TLDR; As you enable the C# 8 “Nullable Reference Types” feature, you may find that applying the
! type hints isn’t enough. You may see that the compiler needs a few extra clues in order to make accurate conclusions about your code. Thankfully, a number of special attributes are available for further describing the nullability of method parameters and return types.
As we saw in Patterns for using Entity Framework in C# 8, the “Nullable Reference Types” feature allows us to write largely null-free code, using compiler warnings to alert us to the limited null values that we do have to care about. Distracting null checks go away, while meaningful null checks come to the forefront. Variable, parameter, and return type declarations are assumed to be never-null unless they are marked as nullable, allowing the compiler to call out apparent contradictions. Three rules cover most scenarios:
- TypeName variable
A variable, parameter, or return type is assumed to be never-null by default.
- TypeName? variable
A variable, parameter, or return type can be marked as possibly-null.
If the compiler determines an expression is possibly-null, but you know that it really won’t be in practice, you can assert that with the “null forgiveness” operator:
While applying the Nullable Reference Types feature to the Fixie test framework, though, I found that merely applying the
! type hints wasn’t quite enough to express the truth about my code. Today, we’ll follow the train of thought from an initial warning message, to a partial solution, to some surprising warnings, and finally reach a complete solution by applying the
First, some background. As your test suite runs, Fixie emits events (This Test Passed!, This Test Failed!, …), and these events are handled by one or more interested parties called “Listeners.” Listeners correspond with the test reporting system that you’re using. If you’re running on Azure DevOps, for instance, then the
AzureListener responds to each event by including them in your build’s “Tests” report screen. If you’re running on TeamCity, then the plain old console output gets modified by
TeamCityListener into a format that TeamCity can process for its own report. Before your tests run, we enlist applicable Listeners:
Analyzing simple nullability warnings
When I first turned on the C# 8 “Nullable Reference Types” feature, I wasn’t too surprised to see some warnings in these many Create methods, because they either return a useful instance or null:
Here, the compiler is claiming that we’re being inconsistent about nulls. The method is declared to return an implicitly-never-null
AzureListener instance, yet we’re clearly contradicting ourselves with an explicit
return null. This kind of warning is pretty typical when you first enable Nullable Reference Types: Old code is littered with examples that suddenly become contradictions in the eyes of the extra strict compiler.
In this case, the right move is simple. We really do want this method to occasionally return null: that’s exactly how it announces that no
AzureListener is applicable. The
Try method, seen above, is responsible for dealing with that possibility. We resolve this warning by simply telling the truth, marking each
Create method with a nullable (
?) return type:
Analyzing Complex Nullability Warnings
Upon rebuilding, my list of warnings was just as big as before! We addressed the warnings within each
Create method, but new warnings pop up back where they’re being used:
It took some time to realize what was going on here. We must think like the compiler. The first argument we pass to
AzureListener.Create, which we just said is a function that returns a sometimes-null
AzureListener?, so in this invocation of
T type parameter is replaced with
AzureListener?. That means our
T is also an
AzureListener? , and so must be the
azure variable in our first
Our warnings on every yield statement begin to make sense. Every time we try to yield an instance returned by one of these
Create methods, we get a warning: that
Listener might be null, like we just declared in each
Create method, but
DefaultExecutionListeners is declared to yield zero or more non-null listeners.
Because we know how each
Create method is really implemented, we can sit back and think really hard and convince ourselves that we’ll never really arrive at the yield statements with a null value. But the compiler isn’t convinced. All it sees is a blatant contradiction.
A natural pitfall
The single biggest mistake people are going to make when using Nullable Reference Types is to blindly follow the warning message. The first warning motivated us to place
? hints where the compiler wanted us to. If we blindly did the same here, though, we would create a growing problem:
This lackluster attempt satisfies the warning, but propagates a lie. We know the
IEnumerable will never contain a null, but we’re saying it will. The code that calls
DefaultExecutionListeners would have to handle nulls that never happen, and the code that calls that would have to handle nulls that never happen. You’d “chase” the warning messages all over your project. Before long, our entire codebase would be littered with inaccurate question marks just to make the compiler stop complaining. Madness! We need a different solution.
Solution: Declare conditional nullability
Instead, we can satisfy the compiler without propagating lies, by telling the compiler about the relationship between our bool return value and our nullable
out parameter. Let’s fully describe the nullability of the
Try method. Our understanding that we would never yield a null comes from our understanding of the bool value returned by
Try. When it returns true, we know that our
out parameter is not null. If the compiler knew that fact as well, then it could conclude that we’d never reach the yield statements with null values. Thankfully, there are a number of nullability attributes for describing the conditional nullability of our methods’ inputs and outputs. We improve the
Try method by saying, “Sure, the
out parameter can be null sometimes, but not when
Try returns true!” We do so by using the
Now, the compiler has as much information as we do. It now knows of the relationship between
Try‘s bool return value and the actual nullability of the
out parameter. Since the yield statements appear within the true branch of the
if statements, it knows they’re not null by that point and will stop suggesting that we propagate
? modifiers through the entire system. No more misleading warnings:
The purpose of the Nullable Reference Types feature is to discover probable bugs while avoiding excessive null checks. For the feature to be effective, though, we need to tell the truth, the whole truth, and nothing but the truth about the nullability of our identifiers. If we stray from that truth-telling ideal, our systems will be covered in misleading type hints and we’ll find ourselves falling back on old-fashioned techniques for dealing with nulls. If we stick to our principles, reacting to each warning purposely and with a clear understanding of why we’re being warned, we can keep our code clean and understandable—as well as correct.