Optional Stacking in TypeScript
Contents
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
andundefined
, 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/
withfp-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 undefined
s, 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 None
s 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.