Nulls and null checks - How to work safely with nulls in any codebase
Spyros Argalias
Posted on January 1, 2022
An important part of clean code is handling nulls properly.
Nulls have been a tricky problem in programming for decades.
Tony Hoare, the inventor of the null
even called it a billion-dollar mistake.
Semantically, nulls are necessary. They represent the absence of a value. For example, a user may fill in a form that has optional fields. They may leave the optional fields blank. That's one reason for nulls.
The problem is that nulls can be difficult to work with and track.
The problem with nulls
Nulls are hard to track in a codebase. There are many things which:
- have properties that are
null
- can return
null
- need to check for
null
before doing something
If you miss a single "null check", you have a bug. Your program might do the wrong thing or even crash.
For example, here is some code that crashes if you forget to check for null
first:
// this function crashes if the argument is null
function foo(arrayOrNull) {
return arrayOrNull[0];
}
The code should have been like this instead:
function foo(arrayOrNull) {
if (arrayOrNull === null) {
return null;
}
return arrayOrNull[0];
}
The issue is that being 100% thorough with your null checks is very hard. It's extremely difficult, if not impossible, to keep track of every null.
Solutions for working with nulls
Working with nulls is difficult. To make things easier, here are some possible solutions you could use. Some of them are bad and some of them are good. We'll go over each one.
The solutions are to:
- place a
null
check around everything - use try / catch instead of null checks
- return a default value instead of
null
- use the null object pattern
- remember to check for every null
- use a programming language with a type system that can track null
- use something like the Option type
Here is each one in more detail:
Place a null check around everything
One solution for dealing with nulls is to always check for them, even when you shouldn't need to. Check "just in case". After all "It's better to have it and not need it than to need it and not have it." - George Ellis. Right?
If this is your only way of ensuring that you don't miss null checks, then maybe...
However, it's not an optimal solution. The problem is that something in your code might be null
when it's not supposed to be. In other words, you have a bug.
But, if you have null checks where they're not needed, you'll silently ignore the bug. It will be swallowed up in a null check.
For example:
// car is never supposed to be null
if (car !== null) {
car.getWheels();
}
In the code above, car
may be null
when it's not supposed to be. That's a bug. However, due to an unnecessary null check, the program won't crash. The bug will be silently ignored.
But, if you didn't have the unnecessary null check, the program would crash.
For example:
// car is null due to a bug
// the program crashes
car.getWheels();
This is a good scenario. As explained in how to respond to errors, at the very least, you want to know that you have a bug. Crashing makes that clear, but silently ignoring bugs doesn't.
In other words, you should probably avoid unnecessary null checks.
Otherwise, if you want to do defensive programming, you can have the extra null checks. However, put in some code that records the bug if the thing is actually null
. That way you can debug the problem later. (For more information please see record errors to debug later.)
Use try / catch instead of null checks
Conditionals vs try / catch is a debate that applies to all possibly invalid actions. For this reason, it's explained more thoroughly in control flow for invalid actions.
That aside, try / catch won't solve the problem.
You might forget to add try / catch blocks, just like you might forget null checks. In this case, your program could crash.
Worse, an exception might be caught, unintentionally, by a different try / catch block. That's a silent bug. Silent bugs tend to be worse than crashes.
Return a default value instead of null
Another option is to avoid returning null
. Instead, return a default value of the relevant type.
For example, you might have a function that would normally return a string or a null. Instead of null, return the empty string. Or, you might have a function that would normally return a positive number or null. Instead of null, return 0 or -1 (if 0 isn't a suitable default).
Benefits of default values
Default values reduce the number of nulls in your code.
In some cases, they also reduce the number of conditionals. This happens when you can treat the default value and the "normal" value the same way.
For example, this code works whether user.name
is a normal value or the empty string.
function printUserGreeting(user) {
const name = user.name;
const formattedName = name.toUppercase();
const greeting = `Hello ${formattedName}`;
document.body.append(greeting);
}
But, if user.name
was sometimes null
, the function would need a null check to work.
function printUserGreeting(user) {
const name = user.name;
if (name === null) { // null check
document.body.append('Hello');
} else {
const formattedName = name.toUppercase();
const greeting = `Hello ${formattedName}`;
document.body.append(greeting);
}
}
Returning default values can be good. However, there are downsides.
Downsides of default values
One downside is that the semantic meaning of null
isn't being honoured. Semantically, null
means the absence of a value. It doesn't mean a legitimate value. In comparison, the empty string or the number 0 could be legitimate values. 0 or -1 could be the result of a math calculation. The empty string may be a delimiter provided to a function. They don't mean the absence of data.
Another downside, related to the first, is that you lose information on whether the value represents null or a legitimate value. Sometimes it's important to differentiate between the two. You won't always be able to use the default value and a normal value in the same way.
For example, consider JavaScript's Array.prototype.indexOf()
method. It returns either a natural number (0 or a positive integer), or -1 as a default value (instead of null). But, in most situations, you can never use the value -1. You'll need a conditional to see if the method returned -1 or a normal value. This defeats the point. From the point of view of your code, it might as well have been null.
For example:
function findUser(userArray, targetUser) {
const index = userArray.indexOf(targetUser);
if (index === -1) {
console.log('Sorry, the user could not be found');
} else {
console.log(`The target user is user number ${index + 1}`);
}
}
Another downside is that you might have many functions. Each might need a different default value. In this case, you'll have a default value that works for one of them, but not for the others. Then, the other functions will need conditionals to check for the default value. Again, this defeats the point. It actually makes the code harder to work with. Checking for null
is easier than checking for "magic values".
Just to finish off, some other downsides are that:
- coming up with a default value can be difficult
- tracing the origin of a default value (in code) can be difficult
Verdict for default values
To summarize: This is a solution which can be helpful to use. However, be careful of the downsides. You'll need to use your own judgement for when to use this option.
Personally, I don't use it too often.
But, one "default" value that's often good to use is an empty collection. For example, an empty array, or an empty hashmap. This tends to have all of the benefits without the downsides. That's because it's semantically correct to say "yes, this thing has a collection, it just happens to be empty". Also, most code should be able to work with an empty collection in the same way as a non-empty collection.
Use the null object pattern
The null object pattern is similar to using default values (mentioned above).
The difference is that it works with classes and objects, rather than primitive values like strings and numbers and such. It sets defaults for values (attributes) as well as behaviour (methods).
You use the null object pattern by creating a null / empty / default object with the same interface as a normal object. The attributes and methods of this object would have default values and behaviour.
For example, here is a normal User
class that you might have in your codebase:
class User {
constructor(name, id) {
this.name = name;
this.id = id;
}
updateName(name) {
this.name = name;
}
doSomething() {
// code to do something
}
}
Here is an example NullUser
class that you might have (a null object):
class NullUser {
constructor() {
this.name = 'Guest'; // default value
this.id = -1; // default value
}
updateName() {} // do nothing (default behaviour)
doSomething() {
// do nothing, or do some other default behaviour
}
}
The usage in code would be something like this: You might have some code that would normally return either null
or a normal object. Instead of returning null
, return the null object. This is analogous to returning a default value.
For example, the code below sometimes returns null
:
function findUser(userId) {
const targetUser = users.find(user => user.id === userId);
if (!targetUser) {
return null;
}
return user;
}
Instead, you can have this code, which returns a null object instead of null
:
function findUser(userId) {
const targetUser = users.find(user => user.id === userId);
if (!targetUser) {
return new NullUser();
}
return user;
}
Then, whenever you use the null object or the normal object, you don't need a null check.
To illustrate the point, here some example code without the null object pattern:
// class User is shown above
const users = [new User('Bob', 0), new User('Alice', 1)];
function findUser(userId) {
const targetUser = users.find(user => user.id === userId);
if (!targetUser) {
return null;
}
return user;
}
function printName(user) {
if (user === null) { // null check here
document.body.append(`Hello Guest`);
} else {
document.body.append(`Hello ${user.name}`);
}
}
function main() {
const user = findUser(123);
printName(user);
}
Here is the same code, except it uses the null object pattern:
// classes User and NullUser are shown above
const users = [new User('Bob', 0), new User('Alice', 1)];
function findUser(userId) {
const targetUser = users.find(user => user.id === userId);
if (!targetUser) {
return new NullUser(); // instead of returning null, return a null object
}
return user;
}
function printName(user) {
// no null check
document.body.append(`Hello ${user.name}`);
}
function main() {
const user = findUser(123);
printName(user);
}
As for whether to use the null object pattern or not, similar points apply as for default values.
Remember to check for every null
One way to be thorough with all of your checks is... to be thorough with all of your checks...
Every time you work on code, be extremely careful with your null checks. You should understand where null
can appear and where it shouldn't appear (where it would be a bug).
It's very difficult. Sometimes it might feel impossible. But, that's what you have to do if you're not using other solutions.
Use a programming language with a type system that can track null
Type systems to the rescue.
Some static type programming languages are able to track null
just like they can track any other type. Then, if something in the codebase could either be null
or another type, they force (or warn) you to have a null check.
Some examples are:
- C# with its nullable reference types
- TypeScript when the
strictNullChecks
option is enabled - Kotlin's nullable reference types
Also, some of these languages have non-nullable types. They can prevent you from assigning null
to a variable altogether. This gives you a guarantee that a variable will never be null
, so you don't need a null check.
For example, using TypeScript (with strictNullChecks
enabled):
let a: string;
a = 'foo'; // works
a = null; // doesn't work, you get a compilation error
let b: string = null; // doesn't work, you get a compilation error
In this case, that variable will never be null
.
In summary, with some type systems:
- you'll be forced, or reminded, to have null checks when you need them. This way, you can never forget a null check.
- you can declare some variables as non-nullable. This means that they'll never be null. The type system will be aware of that and notify you.
Personally, I think that this is a great option.
(Credit to Nicolas Frankel for mentioning non-nullable types.)
Use the Option type
The final option (no pun intended) is to use something like the Option type (also known as the Maybe type).
This doesn't completely eliminate null checks. But, it reduces them a lot. Also, the few remaining null checks are in places where they're easy to work with. It's very difficult to forget to put them in.
With the Option type, you have two null checks instead of a countless number of them.
The null checks are in:
- the Option type itself
- the first function to return an Option type
Here's a (very) simplified implementation of the Option type:
class Option {
constructor(nullOrNormalValue) {
this._value = nullOrNormalValue;
}
map(fn) {
if (this._value === null) {
return this;
}
const newValue = fn(this._value);
return new Option(newValue);
}
}
To do something with the Option type, you use the map
method and pass in a function. This should be familiar if you've ever used a map
function for arrays and such.
The key point here is that the null check is inside the Option type. In other words, every single time you try to use that value, you get a null check for free. This means that, as long as you're working with the Option type, you can never forget your null checks.
You also need a null check, or some other conditional, in the place where you'll return an Option for the first time.
For example, here's a normal function that would normally return null or a normal value:
function getNextScheduledEvent(user) {
if (user.scheduledEvents.length === 0) {
return null;
}
return user.scheduledEvents[0];
}
Here is the same function, but now, it returns an Option.
function getNextScheduledEvent(user) {
if (user.scheduledEvents.length === 0) {
return new Option(null);
}
return new Option(user.scheduledEvents[0]);
}
After writing that code, you don't need any more null checks for the returned value.
For example, here's what the code would look like without Option:
function getNextScheduledEvent(user) {
if (user.scheduledEvents.length === 0) {
return null;
}
return user.scheduledEvents[0];
}
function foo(nextScheduledEvent) {
if (nextSceduledEvent === null) { // null check
// do nothing
} else {
// stuff
}
}
function bar(nextScheduledEvent) {
if (nextSceduledEvent === null) { // null check
// do nothing
} else {
// stuff
}
}
function baz(nextScheduledEvent) {
if (nextSceduledEvent === null) { // null check
// do nothing
} else {
// stuff
}
}
function main() {
const user = {scheduledEvents: []}
const nextEventOption = getNextScheduledEvent(user);
const a = foo(nextScheduledEvent);
const b = bar(nextScheduledEvent);
const c = baz(nextScheduledEvent);
}
Notice that every function needs a null check.
Here is the same code using Option:
function getNextScheduledEvent(user) {
if (user.scheduledEvents.length === 0) {
return new Option();
}
return new Option(user.scheduledEvents[0]);
}
function doubleEventPrice(event) {
// no null check
return {
...event,
price: event * 2,
}
}
function foo(event) {
// stuff, no null check
}
function bar(event) {
// stuff, no null check
}
function main() {
const user = {scheduledEvents: []}
const nextEventOption = getNextScheduledEvent(user);
const a = nextEventOption.map(doubleEventPrice);
const b = nextEventOption.map(foo);
const c = nextEventOption.map(bar);
}
Notice the lack of null checks.
Of course, this is a very simplified explanation. There is much more to using the Option type. A real implementation of Option would also be more much more complicated.
Which option should you use?
We covered a lot of methods for dealing with nulls.
It's up to you to choose the appropriate one for your codebase. You need to weigh the pros and cons of each. You also need to consider your preferences.
Personally, I love the type system enforced null checks. Along with those, I might use default values or the null object pattern sometimes. As of the time of writing, I haven't used the Option type very much. However, many people are passionate about that one. It seems like a great solution.
If you want, leave a comment below about which method you recommend and why.
Final notes
So that's it for this article. I hope that you found it useful.
As always, if any points were missed, or if you disagree with anything, or have any comments or feedback then please leave a comment below.
Alright, thanks and see you next time.
Credits
Image credits:
- Single box - Photo by Christopher Bill on Unsplash
- Two boxes - Photo by Karolina Grabowska from Pexels
- Sticky note - Photo by AbsolutVision on Unsplash
- Pointing to laptop - Photo by John Schnobrich on Unsplash
Posted on January 1, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.