Marshall Bowers

Conjurer of code. Devourer of art. Pursuer of æsthetics.

Optional Stacking in TypeScript

Sunday, May 9, 2021
1,734 words
9 minute read

Nullable references—commonly referred to as the billion dollar mistake—are a familiar sight in many programming languages.

TypeScript (when using strictNullChecks) is one of the better languages when it comes to avoiding the dangers of null, as the compiler and type system can warn us when null rears its ugly head.

However, there are some cases where TypeScript's nullable types (null and undefined) can still impact your code in a negative way.

Today we'll be exploring how to stack optionals in TypeScript and where null and undefined fall short.

Setting Up

This exercise starts, as they often do, with a type. We'll define a PersonTraits object to model the traits of a person:

interface PersonTraits {
  firstName: string;
  lastName: string;
}

Nothing fancy, just a first and last name.

For the purposes of this exercise, we'll also define some additional types and function signatures:

interface Person extends PersonTraits {
  id: string;
}

declare function findPerson(id: string): Promise<Person>;

declare function savePerson(person: Person): Promise<Person>;

The mechanics of these functions aren't really important, but you can imagine these would talk to some persistence layer, like a database.

We can now write an updatePerson function that we can use to update the properties of a Person:

async function updatePerson(id: string, updated: PersonTraits) {
  const person = await findPerson(id);

  person.firstName = updated.firstName;
  person.lastName = updated.lastName;

  return savePerson(person);
}

To update a person, we could do this:

await updatePerson('1', {
  firstName: 'Donald',
  lastName: 'Duck',
});

Partial People

At this point we may have realized that having to pass every property of a Person every time we want to update just one could become rather cumbersome and error-prone. If we only want to update lastName but have to pass in a firstName as well, we run the risk of unintentionally modifying the firstName if the value does not match the current one.

We can update our implementation to allow us to only pass in the properties that we actually want to change:

async function updatePerson(id: string, updated: Partial<PersonTraits>) {
  const person = await findPerson(id);

  if (typeof updated.firstName !== 'undefined') {
    person.firstName = updated.firstName;
  }

  if (typeof updated.lastName !== 'undefined') {
    person.lastName = updated.lastName;
  }

  return savePerson(person);
}

Now we are able to pass just the properties that we actually want to update, leaving the rest unchanged:

await updatePerson('1', {
  firstName: 'Dolan',
});

Adding Optional Properties

Suppose we need to capture some optional data about a person in our PersonTraits type. This could be anything, but for our purposes we'll go with storing a person's favorite bird:

interface PersonTraits {
  firstName: string;
  lastName: string;
  favoriteBird?: string;
}

We're making this an optional property because a person may not have a favorite bird. There could be any number of reasons for this. Maybe they like all birds and can't pick just one as a favorite? Or perhaps they're against having a favorite anything? Of course, we can't ignore that some people just don't like birds.

Whatever the reason, we now have this favoriteBird property that may or may not have a value.

We can modify our updatePerson function to handle updates to favoriteBird:

async function updatePerson(id: string, updated: Partial<PersonTraits>) {
  const person = await findPerson(id);

  if (typeof updated.firstName !== 'undefined') {
    person.firstName = updated.firstName;
  }

  if (typeof updated.lastName !== 'undefined') {
    person.lastName = updated.lastName;
  }

  if (typeof updated.favoriteBird !== 'undefined') {
    person.favoriteBird = updated.favoriteBird;
  }

  return savePerson(person);
}

With that in place, we can now update a person's favorite bird:

await updatePerson('1', {
  favoriteBird: 'duck',
});

This may seem fine, but we now have a problem: our updatePerson function can never clear the favoriteBird property.

If we try to clear it, we'll see this has no effect:

await updatePerson('1', {
  favoriteBird: undefined,
});

The Problem

Let's unpack the problem a bit.

When we first started off, we had a type where every property was required. We then used the Partial type to create a variant of PersonTraits where every property could be optional:

// `Partial<PersonTraits>` is the same as:
type PartialPersonTraits = {
  firstName?: string | undefined;
  lastName?: string | undefined;
};

In our updatePerson function we could now check if the type of a given property was undefined to know whether the value was absent.

However, this approach falls apart when an optional property is added to our PersonTraits type:

interface PersonTraits {
  firstName: string;
  lastName: string;
  favoriteBird?: string;
}

// `Partial<PersonTraits>` is the same as:
type PartialPersonTraits = {
  firstName?: string | undefined;
  lastName?: string | undefined;
  favoriteBird?: string | undefined;
};

In both PersonTraits and Partial<PersonTraits>, favoriteBird has the type string | undefined. The result of this is that we can no longer tell the difference between a value for favoriteBird being omitted or a value of undefined being passed.

The underlying problem here is that TypeScript does not support stacking optional types. Defining a type of string | undefined | undefined is the same as string | undefined, due to flattening.

While it is possible to simulate one level of optional stacking by making use of both null and undefined, this does not serve as a general-purpose solution to the problem.

Introducing Option

The good news is there is a solution to our problem!

The Option type (also known as Maybe) is a container type that can be used to model a value that may or may not have a value. This type can be defined in TypeScript using a discriminated union:

type Option<T> =
  | { tag: 'Some'; value: T }
  | { tag: 'None' };

If the tag is 'Some' then the Option contains a value of type T. If the tag is 'None' then the Option represents the absence of a value.

We're going to be using fp-ts—a functional programming library for TypeScript—for this, which comes with its own Option type out of the box, so we won't need to define it ourselves.

If you're following along, you may have to replace fp-ts/ with fp-ts/lib/ in the import statements, depending on your module configuration.

A Partial for Options

We can define our own mapped type that behaves like Partial, but with an additional layer of optionality powered by Option:

import { Option } from 'fp-ts/Option';

type PartialOption<T> = {
  [P in keyof T]?: Option<T[P]>;
};

We can then use PartialOption in the same way we used Partial before:

import { identity, pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';

async function updatePerson(id: string, updated: PartialOption<PersonTraits>) {
  const person = await findPerson(id);

  const noop = (): void => undefined;

  const withUpdatedValue =
    <T>(f: (value: T) => void) =>
    (property: Option<T> | undefined) =>
      pipe(property, O.fromNullable, O.chain(identity), O.match(noop, f));

  pipe(
    updated.firstName,
    withUpdatedValue(firstName => {
      person.firstName = firstName;
    }),
  );

  pipe(
    updated.lastName,
    withUpdatedValue(lastName => {
      person.lastName = lastName;
    }),
  );

  pipe(
    updated.favoriteBird,
    withUpdatedValue(favoriteBird => {
      person.favoriteBird = favoriteBird;
    }),
  );

  return savePerson(person);
}

The implementation of this function looks a little different from before, so let's take a peek at what's going on under the hood.

The first thing to note is that we're now using pipe from fp-ts to streamline our code. pipe takes a value and then pipes it through a pipeline of functions, like so:

const n = pipe(
  3,
  // x === 3
  x => x * 2,
  // y === 6
  y => y + 1,
);
// n === 7

The other is that we've added a withUpdatedValue helper function to cut down on some of the boilerplate needed for handling each updated value. Here it is again with some explanatory comments:

// A function that does nothing (a no-op), used for discarding a `None` value.
const noop = (): void => undefined;

const withUpdatedValue =
  <T>(f: (value: T) => void) =>
  (property: Option<T> | undefined) =>
    pipe(
      property,
      // Convert from an `Option<T> | undefined` to an `Option<Option<T>>`
      O.fromNullable,
      // Flatten an `Option<Option<T>>` to `Option<T>`
      O.chain(identity),
      // If `Option<T>` is `Some` we extract the value and pass it as the argument
      // to `f`, otherwise we call `noop` to do nothing.
      O.match(noop, f),
    );

With our new updatePerson in place, we can now solve our original issue:

const withFavoriteBird = await updatePerson('1', {
  favoriteBird: O.some('goose'),
});

const noFavoriteBird = await updatePerson('1', {
  favoriteBird: O.some(undefined),
});

Going All-in on Option

While our PartialOption type did solve the optional stacking issue from before, it was a little confusing having to deal with both undefined and Option, especially when converting from one to the other.

Let's take a look at what this would look like if we go all-in on Option.

The first thing we do is change the type of the favoriteBird property:

interface PersonTraits {
  firstName: string;
  lastName: string;
  favoriteBird: Option<string>;
}

We are now using an Option to model the requirement that a person may or may not have a favorite bird.

We'll also need to create an equivalent type to Partial that works with Option instead of undefined:

import { Option } from 'fp-ts/Option';

type Optional<T> = {
  [P in keyof T]: Option<T[P]>;
};

Using this Optional type with updatePerson, we can see that our implementation looks much closer to what we had back when we were dealing with undefineds, without the need for a complex helper function:

async function updatePerson(id: string, updated: Optional<PersonTraits>) {
  const person = await findPerson(id);

  const noop = (): void => undefined;

  pipe(
    updated.firstName,
    O.match(noop, firstName => {
      person.firstName = firstName;
    }),
  );

  pipe(
    updated.lastName,
    O.match(noop, lastName => {
      person.lastName = lastName;
    }),
  );

  pipe(
    updated.favoriteBird,
    O.match(noop, favoriteBird => {
      person.favoriteBird = favoriteBird;
    }),
  );

  return savePerson(person);
}

This works very much the same way as before:

const withFavoriteBird = await updatePerson('1', {
  firstName: O.none,
  lastName: O.none,
  favoriteBird: O.some(O.some('goose')),
});

const noFavoriteBird = await updatePerson('1', {
  firstName: O.none,
  lastName: O.none,
  favoriteBird: O.some(O.none),
});

One thing to note is that we now have to pass a None for every property we don't want to update. We could clean this up a bit by adding a default object containing all Nones and then passing just the properties we want to update:

const updatedPersonDefault: Optional<PersonTraits> = {
  firstName: O.none,
  lastName: O.none,
  favoriteBird: O.none,
};

const withFavoriteBird = await updatePerson('1', {
  ...updatedPersonDefault,
  favoriteBird: O.some(O.some('goose')),
});

const noFavoriteBird = await updatePerson('1', {
  ...updatedPersonDefault,
  favoriteBird: O.some(O.none),
});

Wrapping Up

We've now seen one instance where null and undefined fall short and how making use of Option can overcome these shortcomings.

If you're using TypeScript today, I'd encourage you to check out fp-ts to improve your TypeScript code. Even a slight sprinkling of the many features it provides can make a world of difference.