Optional Stacking in TypeScript
Nullable references are a familiar sight in many programming languages. Today we'll be exploring how to stack optionals in TypeScript and where null and undefined fall short.
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.
This exercise starts, as they often do, with a type. We'll define a `PersonTraits` object to model the traits of a person:
Nothing fancy, just a first and last name.
For the purposes of this exercise, we'll also define some additional types and function signatures:
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`:
To update a person, we could do this:
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:
Now we are able to pass just the properties that we actually want to update, leaving the rest unchanged:
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:
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`:
With that in place, we can now update a person's favorite bird:
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:
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:
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:
In both `PersonTraits` and `PartialPersonTraits`, `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.
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:
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](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html) that behaves like `Partial`, but with an additional layer of optionality powered by `Option`:
We can then use `PartialOption` in the same way we used `Partial` before:
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:
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:
With our new `updatePerson` in place, we can now solve our original issue:
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:
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`:
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:
This works very much the same way as before:
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:
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.