Oliver Joseph Ash

Full-stack web developer

Why use a Maybe type in JavaScript?

The Maybe type is a popular abstraction for defining values that may or may not exist. Many languages use it in place of nullable values. For example, it can be found in Haskell, Elm, Scala (Option), and Swift (Optional). In this blog post, I try to explain the advantages of using a similar abstraction specifically as it applies to JavaScript.

Most of my work over the past 4 years has been based around JavaScript programs. I’ve lost count of the number of times I’ve seen bugs because of:

  • unexpected undefined/null
  • boolean coercion

Many bugs appear because of human error, and these types of bugs fit into that category. I want to protect myself, and others, from repeating these mistakes. We’re only human after all.

In JavaScript, Maybe can be seen as an alternative to these bad parts of JavaScript—namely nullable values and boolean coercion. In practice, Maybe enforces constraints which mean we can eradicate these types of bugs from our programs.

Before looking at Maybe, let’s have a look at what these bugs look like in a world without Maybe.

Note: these examples use the Maybe type as it is defined in Folktale’s data.maybe.

No more unexpected undefined/null bugs

JavaScript has nullable values (undefined and null) to express empty cases. For example:

// Type is Array<{ id: number, name: string }>
const users = [
    { id: 1, name: 'Bob' },
    { id: 2, name: 'Saffron' }
]
// Type is { id: number, name: string } | undefined
// This will evaluate to undefined
const loggedInUser = users.find(user => user.id === 5);
// => undefined

loggedInUser could be undefined—given any set of users with this type, we can’t be certain what loggedInUser will evaluate to until runtime.

Nonetheless, JavaScript allows us to go ahead and use our loggedInUser variable as if it’s always safe to do so.

Assuming loggedInUser will never be undefined, this code is fine, until all of a sudden this assumption breaks down.

// If loggedInUser is undefined, this will throw an
// exception
`Hello, ${loggedInUser.name}`;
// => Uncaught TypeError: Cannot read property 'name'
// of undefined

This is a bug that can easily be written into a program, and unless we somehow remember to handle this edge case, we won’t discover the bug until it eventually manifests itself in some production code.

ProTip: In TypeScript with the strictNullChecks option enabled, the compiler will force you to handle the undefined case in this example:

`Hello, ${loggedInUser.name}`;
//        ^^^^^^^^^^^^
//        Object is possibly 'undefined'

JavaScript allows you to use nullable values in strings. This is nearly always a programming mistake, but you won’t even get a runtime error:

const users = { 1: 'Bob', 2: 'Saffron' }; // Record
// Type is { id: number, name: string } | undefined,
// will evaluate to undefined
const loggedInUserName = users[5];
const message = `Hello, ${loggedInUserName}`;
// => "Hello, undefined"

(This one would not be caught by TypeScript’s strictNullChecks option—see issue #7989 for details.)

Likewise, JavaScript allows us to use a nullable value in calculations, in which case you will likely see unexpected NaNs.

If we used a Maybe type, it wouldn’t be possible to write these bugs. This is because you’re forced to:

  • map over the Maybe to use the inner value
  • provide a default when finally reading the inner value with getOrElse.

Let’s look at an example:

const users = [
    { id: 1, name: 'Bob' },
    { id: 2, name: 'Saffron' }
];
// Array<{ id: number, name: string }>
// Type is Maybe<{ id: number, name: string }>
const maybeLoggedInUser1 = Maybe.fromNullable(
    users.find(user => user.id === 5)
);
// => Nothing
const message1 = maybeLoggedInUser1
    .map(loggedInUser => `Hello, ${loggedInUser.name}`)
    .getOrElse('');
// => ''
// Type is Maybe<{ id: number, name: string }>
const maybeLoggedInUser2 = Maybe.fromNullable(
    users.find(user => user.id === 2)
);
// => Just<{ id: 2, name: 'Saffron' }>
const message2 = maybeLoggedInUser2
    .map(loggedInUser => `Hello, ${loggedInUser.name}`)
    .getOrElse('');
// => 'Hello, Saffron'

Using this abstraction, this whole class of bugs (unexpected undefined/nulls) can be eradicated from our programs.

No more boolean coercion bugs

In JavaScript, we can use a value of any type in conditionals. JavaScript dynamically converts our value into a boolean on-the-go, a process known as coercion:

const array1 = [1,2,3];
if (array1.length) {
    // Code here will be ran
}
!!array1.length;
// => true
const array2 = [];
if (array2.length) {
    // Code here will not be ran
}
!!array2.length;
// => false

When referring to a value that will undergo boolean coercion, we call it truthy or falsy. In this example, array.length is a truthy value, unless the array is empty, in which case it is falsy.

const users = { Bob: 0, Saffron: 10 };
const getAge = name => {
    // Type is number | undefined
    const maybeAge = users[name];
    // maybeAge is implicitly coerced into a boolean
    const message = maybeAge
        ? `${name} is ${maybeAge}`
        : 'No age';
    return message;
};
getAge('Saffron') // => Saffron is 10
getAge('Luke') // => No age

Assuming maybeAge will never be 0, this code is fine, until (again) all of a sudden this assumption breaks down. If maybeAge is 0, it will be coerced into a boolean as false, so our function wrongly interprets this as “age does not exist”:

getAge('Bob') // => No age (!)

This is a bug that can easily be written into a program, and unless we somehow remember to handle this edge case, we won’t discover the bug until it eventually manifests itself in some production code.

To fix this, we must make our condition explicit, instead of relying on JavaScript’s boolean coercion:

const users = { Bob: 0, Saffron: 10 };
const getAge = name => {
    // Type is number | undefined
    const maybeAge = users[name];
    // Type is string
    const message = maybeAge !== undefined
        ? `${name} is ${maybeAge}`
        : 'No age';
    return message;
};
getAge('Saffron') // => Saffron is 10
getAge('Luke') // => No age
getAge('Bob') // => Bob is 0

This is correct, and in this example this would probably suffice. However, when we need to chain calculations, we sacrifice expressiveness for correctness:

const getNextAge = name => {
    // Type is number | undefined
    const maybeAge = users[name];
    // Type is number | undefined
    const maybeNextAge = maybeAge !== undefined
        ? maybeAge + 1
        : undefined;
    // Type is string
    const message = maybeNextAge !== undefined
        ? `${name}’s next age is ${maybeNextAge}`
        : 'No age';
    return message;
};

Relying on JavaScript’s implicit boolean coercion, as we saw above, can be perfectly safe in some use cases, depending on the type and expected behaviour. For that reason it is possible to selectively rely on boolean coercion when we know it is safe to do so. However, this approach requires a lot of discipline and is prone to human error.

If we want to avoid boolean coercion bugs in our code without having to require discipline or extra verbosity, we can use a Maybe type.

We can think of Maybe as an abstraction over the condition checking “does this value exist” (maybeX !==undefined), with an API for performing common operations to the inner value:

// map
const maybeNextAge = maybeAge !== undefined
    ? maybeAge + 1
    : undefined;
const message = maybeNextAge !== undefined
    // map
    ? `${name}’s next age is ${maybeNextAge}`
    // getOrElse
    : 'No age';

Finally, here is how our example code looks using Maybe:

const getNextAge = name => {
    // Type is number | undefined
    const maybeAge = Maybe.fromNullable(users[name]);
    // Type is string
    const message = maybeAge
        .map(age => age + 1)
        .map(nextAge => `${name}’s next age is ${nextAge}`)
        .getOrElse('No age');
    return message;
};

With Maybe, we never have to write a condition to check if a value exists or not. Our code is more concise, and most importantly, we can no longer write boolean coercion errors into our program.

ProTip: If you’re a TypeScript user, tslint has a rule to disallow boolean coercion called strict-boolean-expressions.

Caveats

Where JavaScript may return “nothing”, it will return undefined or null. In practice, this means you end up defining helper functions to handle the conversion to a Maybe. Of course, you then must remember to use these helper functions. (In languages with native Maybe types, this is usually the standard for most APIs.)

Maybe implementations

Choose your weapon from any of these libraries which provide decent implementations of the Maybe type (also called Option). The differences are mostly insignificant. I would personally pick something that conforms to the Fantasy Land spec, as this will provide interoperability with other conforming Maybe implementations, and also with libraries which rely on the interfaces defined in this spec, such as Sanctuary and Ramda.

Conclusion

Maybe enforces constraints which make it impossible to write certain types of common bugs into our programs.

In JavaScript, there are many places we must handle nullable values, such as when accessing a value from an array with bracket notation or Array.prototype.find. However, it’s easy to forget to handle these null cases. By switching to APIs that return Maybe instead of nullable values, we are forced to handle all cases—when the value does and does not exist.

JavaScript’s boolean coercion is often thought as convenient, but it’s easy to forget about the nuances of this process. As we have seen, boolean coercion does much more than just check for existance, so in practice it is not safe to use non-boolean values as conditions. Maybe perfectly captures the idea of a value that may or may not exist, and by adopting this abstraction, we can no longer repeat this mistake.

There are most definitely more advantages and disadvantages to using a Maybe type in your JavaScript that I haven’t touched upon here. If you have any comments or thoughts, I would love to hear them. Get in touch on Twitter. :-)

Huge thanks to those who reviewed this article: Sébastien Cevey, David Rapson, and Tom Harding.