Pernicious Nulls - using and abusing nulls in C#

In this post, I would like to introduce a new code smell:

The Pernicious Nulls Code Smell
The propagation of nulls throughout a codebase that obscure ideas and concepts

At least I think it's new; there are smells/anti-patterns about the dangers of using null, but this one focuses on the understandability of software that's infused with nulls.

Nulls can spread throughout a codebase like garden weeds, strangling the intent and meaning of code. Let's look at the definition of these words:

Pernicious - having a very harmful effect on somebody/something, especially in a way that is not easily noticed (Oxford English Dictionary)

and...

Null - having no legal force (Cambridge dictionary)

A major part of software is defining contracts between components. How we can we define contracts with parts that have no legal force?

In this post we'll look at how nulls can obscure ideas and concepts, and then look at alternatives; including 'alternatives of representing nothing', and 'alternatives to what nothing means'.

Nulls are often a cause of bugs because they cause runtime exceptions when they're referenced. Nullable Reference Types (NRTs) were added to C# 8.0 and provide compile time information on nulls. But is it enough on its own, and how does it compare to the alternatives?

Although the examples in the post are in C#, the main concepts described are applicable to other languages that have nulls.

This post was written because I see more and more code that relies on nulls; code that looks innocuous, but its effects are pernicious.


Life Before Nullable Reference Types #

First, let's look at life before NRTs. Here is an example that blows up at runtime:

Person p = null;
Console.WriteLine(p.Name); 💥

That's obviously a bug because the declaration of p is on the very line before the use of p.

But what if the instance was created somewhere else?

public void ProcessPerson(Person p) {
Console.WriteLine(p.Name); // 💥 or ✅ ?
}

Will this blow up? ... "Who knows!" is the answer; it's far from obvious. You'll need to check it to find out:

public void ProcessPerson(Person p) {
if(p is not null) {
Console.WriteLine(p.Name); // ✅
}
}

The fundamental thing to observe here is that we don't know why we were given a null value. Maybe it was a mistake by the programmer who wrote the calling method? Maybe the value was stored as null somewhere, and something read it and gave it to us. But anyway, we check it before using it, so things are probably OK. 🤔 It can all seem a bit vague.

Having said that, I do think there are places where null is useful, for instance, in small private methods, or in general frameworks. For example:

private static Connection? TryGetPooledConnection(string name) {
... returns null if no pooled connection exists
}

To me, it's pretty clear what null means here. It means there isn't a pooled connection of that name. People wondering what the null means wouldn't have to look far to find out, because it's from a private method in that type. But if there were multiple reasons why a null could be returned, and the caller needed to differentiate, then a null wouldn't be the right choice.

Life Since Nullable Reference Types #

With NRTs, we can now place a nice little ? on the parameter. It says "This could be null, best you check it before using it!":

public void ProcessPerson(Person? p) {
if(p is not null) {
Console.WriteLine(p.Name); // ✅
}
}

"Yeah, yeah, I know all this"... please keep reading - it does get more interesting soon!

The ? gives us (the readers of the code) slightly more information. It also gives the compiler more information: it can now tell us about potential mistakes at compile time rather than at runtime. For instance, if we tried to use the parameter without the null check, we'd get:

warning CS8602: Dereference of a possibly null reference

Conversely, a lack of ? means "This isn't null". So, going back to the first bit of code in this post:

public void ProcessPerson(Person p) {
Console.WriteLine(p.Name); // 💥 or ✅ ?
}

Does it blow up or not? Just as the ? said that something could be null, the omission of it means it's probably not null.

Notice the words could and probably not; unfortunately, it's impossible to be any more definite than that when dealing with nulls.

So, with NRTs, we have more information on whether something is or isn't null. The phrase Is or Isn't null doesn't really give us any meaning though...

The Meaning of Null #

42 is the answer to Life, the Universe, and Everything. But even that can't tell us what a null parameter means. What is the intent given to that null (in the example above, what is the intent given to the null Person?)?

null means nothing, so what intent can be applied to it?

Null References are often referred to as the Billion Dollar Mistake. As well as software crashes, they also create opportunities for malware and viruses. So why do we still use them? Why was NRT invented? Was it to promote more use of null? Or was it to make existing code that uses nulls easier to reason about?

If we look at the documentation for NRTs, it says:

Nullable reference types refers to a group of features introduced in C# 8.0 that you can use to minimize the likelihood that your code causes the runtime to throw System.NullReferenceException

It mimimizes the likelihood of crashes.

Why doesn't it STOP the likelihood of crashes?

It's because null means nothing and it's impossible to convey any meaningful intent to it.

But, what does intent mean? If I give you a Person object, and it's not null, then all well and good. If I give you a Person object and it's null, what is my intent? Is a null Person still a Person? (in C#, nulls are type-less so the answer is technically "No"). Does null mean that the person doesn't exist in the database? Or is it accidentally null because there was a problem with the logic of the software that retrieved the data?

It probably means that the person doesn't exist, but it allows the other issues to go unchecked; it hides the fact that things could've gone wrong in the code. So my intent (the person might not exist) isn't a strong intent; it could be that the person doesn't exist, but it could also be due to some accident that happened beforehand resulting in a null value.

Stronger Intents #

Let's focus on the Person parameter that is passed to the method:

To convey more intent that something may or may not be be present, we could use a type to describe that intent more fully:

public void ProcessPerson(Maybe<Person> person)

Maybe is a type that may contain a Person. It is a functional programming concept (named Option in other functional languages), and there are many implementations of it in .NET (e.g. this one). Here is a good description of it from Mark Seemann

Maybe enables you to model a value that may or may not be present. Object-oriented programmers typically have a hard time grasping the significance of Maybe, since it essentially does the same as null in mainstream object-oriented languages. There are differences, however. In languages like C# and Java, most things can be null, which can lead to much defensive coding. What happens more frequently, though, is that programmers forget to check for null, with run-time exceptions as the result.

With a Maybe<Person>, the intent is now stronger; you're explicitly stating that the person may be given to you. It looks pretty similar to the Person? version, but with one major difference: we can't accidentally end up with with a null person.

With Maybe, you cannot directly access the value and you cannot accidentally create one.

To create the value, you use a non-null value, or, you create one via None - it's explicit:

var person = isActiveInDatabase ? new Person().ToMaybe() : Maybe<Person>.Nothing;

ProcessPerson(person);

To get the value, you can either ask for 'the value or an explicit fallback value', or you can specify two branches, e.g.

public static void ProcessPerson(Maybe<Person> p) {
p.Match(
p => Console.WriteLine(p.Name),
() => Console.WriteLine("Nothing to process."));
}

I won't go into any more detail of Maybe, but they're very useful and make code easier to read because they allow for stronger intents.

Even Stronger Intents #

What else could we do to make our intent even stronger? How about:

public void ProcessPerson(PersonProcessingRequest request)

The request could provide information, not just on the Person itself, but the metadata about the person, e.g. how and when that person was retrieved, are they inactive, have they requested anonymity via GDPR, etc. A null could represent any of these examples, but we wouldn't know which one.

PersonProcessingRequest is just an example that I thought of. Below I describe a real scenario that I encountered recently and how we eliminated null by using a more explicit model.

Nulls That Hide Concepts #

During a recent code review, I asked what the null meant on this type:

public record AcmeUser(string Name, string? NtLogin, bool IsActive);

(ACME Corp provide Loans)

The author seemed rather taken aback, and replied "Because it can be null". Although the reply was correct, I sensed that it also contained a hint of 'WTF are you talking about with your stupid questions?!' It also rather reminded me of the 'you are in a helicopter' joke!

So, we had a face-to-face conversation to drill down just a tiny bit deeper on what the null meant. We looked at how that type was created; it was created from rows in a database table. It was null in the table, so it should be null here, right?

So, we tried to find out why it was null in the database. After some digging around, we discovered a pattern: every group or system account had a null NtLogin property, but every real person had a value for this column.

Now we were getting somewhere. Now we'd discovered, after a 15 minute conversation, what that seemingly-innocuous ? meant.

So, with this new-found insight, we asked ourselves should this field be null for our use of AcmeUser?

We dug further still. We concluded with a resounding NO it should not! The reason: AcmeUser is used in this particular part of the system to represent someone who signs off Loans. A loan can never be signed off by a group, or by a system account.

So, this is was a bug waiting to happen. The ? was used as a scapegoat for a) a lack of knowledge on our part for what the null truly meant, and b) a lack of a more specific type to represent the domain idea.

Our domain idea here, is that there is somebody in the Loans Administration Department that can Sign Off a Loan. So, what we needed, instead of that general type with a null property, was a model looking something like this:

public record LoanAdministrator(string Name, string NtLogin);

The benefits here are many:

What Have We Seen? #

We've seen that NRTs add some context when dealing with nulls. I think that the use of nulls and nullable reference types, are OK if used in small and specific areas; areas where spotting a null doesn't leave you wondering where to look to find out the intent (e.g. in the example used above, TryGetPooledConnection).

But beyond small and specific areas, nulls become troublesome. When they're spread throughout an entire system or domain, they become pernicious: it's not clear what they mean and trying to find out is time consuming, confusing, and error-prone.

We've see how functional concepts such as Maybe / Option can make intent clearer.

And we've seen that, by specifying specific types for a specific job, we can remove the reliance on nulls altogether.

What do you think? Every time I see a null used, I have to stop and think and that makes code much harder to read. But I feel like I'm in an ever-shrinking minority and that nulls are gaining in popularity. Feel free to leave a comment or get in touch with me on Twitter.


image generated by Craiyon image generator

🙏🙏🙏

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please 🦋 ping me on Bluesky! 🦋

Leave a comment

Comments are moderated, so there may be a short delays before you see it.

3 comments on this page

Published