Nulls and null checks - How to work safely with nulls in any codebase

Nulls and null checks - How to work safely with nulls in any codebase

Nulls and null checks have been a tricky problem in programming for decades. Thankfully, there are many solutions to make them easier to work with.

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 type system that forces you to check for every 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 type system that forces null checks

Type systems to the rescue. Some programming languages, such as TypeScript with its strictNullChecks option, understand when something could be null. Then, they force you to check.

Another example is C# with its nullable reference types. These warn you to check (they don't force you), but that's still helpful.

This means that you can never forget to check for null.

Personally, I think that this is a great option.

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:

  1. the Option type itself
  2. 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