Skip to main content

Rethinking Null

06-07-23 Emma Litwa-Vulcu

Null is often treated as a useful representation of no data, but maybe we should rethink this. As one Sparkboxer was learning more about Rust, they discovered that the best way to handle null should be to configure it as a fail state as early as possible.

Every developer is familiar with null. Put simply, null is the absence of data. It’s what you have when you have nothing—but not zero things, that’s different.

I’ve typically thought of null as a valuable tool to describe a specific state of data. If updated_at is null, the row has never been updated. If message is null, there was no message sent, and the behavior can change. But recently I’ve delved into the world of Rust where null... doesn’t quite exist, at least not in the same way.

Looking at other languages that aren’t so strict, I’ve started rethinking my approach to how I handle null cases and when. Instead of relying on null to tell me something, I’ve instead started thinking about how I can handle null cases early on and then never think about them again. In other words, I’ve been thinking about how I can fail fast on null cases and thereby limit bugs and reduce code overhead elsewhere in the codebase.

The Billion Dollar Mistake

The very concept of null has an interesting past. Although used extensively, it isn’t really necessary—at least not in the way it’s typically used. It’s just a convention we came up with that stuck. The developer who first wrote it has regretted it ever since.

“I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.”

Tony Hoare from Null References: The Billion Dollar Mistake

When a type is nullable, we have to check if a value is null before we act on it. If we don’t, at best we get bugs or undefined behavior. At worst, we open security holes in our code. When languages require us to check for null, such as Rust, this potential is limited. Null cases are easy to ignore in languages that aren’t so exacting.

Rust and Null

Modern languages are rethinking null. Rather than have nullable types, Rust uses the null-safe type Option. This means a function can always return the same type, but with or without data. Dart, C#, Scala, Kotlin, Swift, Java, and many other languages support similar null-safe types.

Here’s an example of handling null cases in Rust:

fn checked_division(dividend: i32, divisor: i32) -> Option<i32> {
    if divisor == 0 {
        None
    } else {
        Some(dividend / divisor)
    }
}

fn main() {
    let result: Option<i32> = checked_division(10, 2);
    if let Some(r) = result {
        // r = 5
    } else {
        // no r variable, checked_division returned None
    }
}

Rather than returning a 32-bit integer, the checked_division function returns an Option enum that can contain a 32-bit integer. In order to access that data, the variable must be checked to verify whether Some data exists or None using an if let expression In actuality, None does refer to a null value. By wrapping null—None—within the Option null-safe type, the developer is forced to handle the case.

More Flavors of Null

Instead of limiting null cases, Javascript, a much older language, takes the opposite approach. It has three different “there is no data” cases: Null, Undefined, and NaN.

A result of undefined means that a property exists, but has no value. In most languages, this means the variable is uninstantiated. Javascript, however, secretly gives all variables a value/type of undefined if they don’t have one.

If that weren’t enough, Javascript expands further on the concept of null, adding a fifth possible logic state as a global attribute: “Not a Number.” In a strongly typed language, you would get an error if you tried to divide 10 by “a”. In Javascript, you get a result: NaN.

if (typeof num_users == 'undefined') {}
if (num_users == null) {}
if (isNaN(num_users)) {}
if (num_users == 0) {}
if (num_users > 0) {}

In practice, no one should write code like this. You shouldn’t run into a situation where you need to handle this many logic cases, and if you do, your problem lies elsewhere. But there are very often situations where one or more null cases should be handled. And as developers, do we always account for a null result versus a false result versus undefined versus NaN versus 0 versus an empty array []? No. No, we don’t. That’s probably, generally, a good thing. But it’s a very common area for bugs to pop up because it’s very easy to forget an edge case where the value you’re looking for simply… isn’t there.

There are arguments for why these additional values are useful, but the cons significantly outweigh any pros. Here’s a wonderful snippet for consideration:

let a = NaN;
console.log(a === a); // false

Developers should always try to think of edge cases and handle failures gracefully. However, it’s impossible to get every edge case. “The only bug-free code is code you’ve never written,” as the saying goes. By shifting my thought process around null values and writing them out as early in the code as possible, I’ve found I not only write less code overall, I also don’t have to worry about as many edge cases.

Writing for APIs

Writing for an API might be the most obvious example of my thought process.

In an API response such as below, properties will often be omitted if there is no value and no semantic reason for returning a property.

{
  posts: [
    { id: 1, body: 'hello world' },
    { id: 2, body: 'hello again', comments: [{...}, {...}] }
  ]
}

In writing a model for the above response, it would be easy to allow the comments property to be null locally. A value of null would accurately reflect the data returned from the API. But that necessarily increases code complexity elsewhere:

// Bad developer! No cookie. (Cannot read properties of undefined.)
const sum_comments = (posts) => {
  let sum = 0;
  posts.forEach(post => sum += post.comments.length);
  return sum;
}

// Technically works. Not a great solution.
const sum_comments = (posts) => {
  let sum = 0;
  posts.forEach(post => {
    if (typeof post.comments != 'undefined' && post.comments.isArray()) {
      sum += post.comments.length;
    }
  });
  return sum;
}

// Also works, depending on your transpiler, but still unnecessarily
// complex and, more importantly, still relies on the developer to
// remember to check for null cases.
const sum_comments = (posts) => {
  let sum = 0;
  posts.forEach(post => sum += post.comments?.length ?? 0);
  return sum;
}

In Rust, we could parse this data as so:

use serde::{Serialize, Deserialize};

# [derive(Serialize, Deserialize)]
struct Post {
    pub id: u64,
    pub body: Option<String>,
    pub comments: Option<Vec<Comment>>,
}

# [derive(Serialize, Deserialize)]
struct Comment {
    // ...
}

/// Sum the number of comments in a vector of Posts.
fn sum_comments(posts: Vec<Post>) -> u32 {
    let mut sum: u32 = 0;
    for post in posts {
        if let Some(comments) = post.comments {
            sum += comments.len() as u32;
        }
    }
    sum
}

In this example, we always know the type of new_user.name is Option<u32> under any circumstances. We can safely parse it the same way anywhere in our code. However, it’s not an efficient way of working as the developer using the code must always account for the None condition.

Defaults to Handle Null

Instead of letting these values null, we can handle these cases early on by adding default values, thereby circumventing handling them multiple times later on.

With Rust, adding an attribute to create default values is straightforward. Alternatively, a constructor or factory function could be written, and might often be the better solution in order to abstract the API. Regardless of the approach, the possibility of a null value is written out and all further code simplifies.

use serde::{Serialize, Deserialize};

# [derive(Serialize, Deserialize)]
struct Post {
    pub id: u64,
    #[serde(default)]
    pub body: String,
    #[serde(default)]
    pub comments: Vec<Comment>,
}

# [derive(Serialize, Deserialize)]
struct Comment {
    // ...
}

/// Sum the number of comments in a vector of Posts.
fn sum_comments(posts: Vec<Post>) -> u32 {
    let mut sum: u32 = 0;
    for post in posts {
        sum += post.comments.len() as u32;
    }
    sum
}

// Or, more succinctly:
fn sum_comments(posts: Vec<Post>) -> u32 {
    posts.iter().map(|p| (p.comments.len() as u32)).sum()
}

In TypeScript, it gets a little more involved as we don’t know the type of data we’re looking at. Consider enabling strictNullChecks and strictPropertyInitialization for TypeScript if you don’t have them on already (default is true if strict is enabled). But since TypeScript is transpiled to JavaScript and we’re working with unknown data from an API, those flags don’t solve our problem. One way of sanitizing the data and adding default values is by writing a simple factory function:

interface Post {
  id: number;
  body: string;
  comments: Comment[];
}

interface Comment {
  //...
}

function CreatePost(obj: any): Post {
  return {
    id: obj.id,
    body: obj.body ?? "",
    comments: obj.comments?.length ?
      obj.comments.map((c: any) => CreateComment(c)) :
      [],
  } as Post;
}

function CreateComment(obj: any): Comment {
  //...
}

// Sum the number of comments in an array of Posts.
function sum_comments(posts: Post[]): number {
  return posts.map(p => p.comments.length).reduce((a, b) => a + b);
}

The sum_comments function above is now a single line and easy to understand—there’s no need for the developer to remember to handle null cases. And it’s less likely it will need to be refactored if the logic around null/missing data changes in the API.

We could alternatively write these factory functions as constructor methods on classes or within the context of a larger API abstraction, as is often the case. The key here is that by specifically writing out null cases, we’re both simplifying code elsewhere as well as limiting the potential for bugs in every circumstance an otherwise nullable property is used.

Conclusion

There are two schools of thought circling the use of null—three-valued logic (true, false, and null) and two-valued logic (true or false). It’s easy to use null in a falsy context and treat nullable types as two-valued logic. But that simply isn’t the case under the hood. Failing to handle null cases is a fast track to bugs and failing to handle them early means they have to be handled over and over elsewhere, leading to bloated code and more potential bugs.

My takeaway from how Rust handles null is to not let null directly represent a third logic case. Instead, it should be written out as early as possible. If a third (or fourth or fifth) logic case is needed, write one.

Related Content

See Everything In

Want to talk about how we can work together?

Katie can help

A portrait of Vice President of Business Development, Katie Jennings.

Katie Jennings

Vice President of Business Development