An Option type in Typescript
2023-10-06
In my day-to-day work, I almost exclusively code in Typescript. It adds much-needed type safety, predictability, and guidance to JavaScript code. That said, there are some features present in other strongly-typed languages that I wish we had access in Typescript. In this post, I’ll explain how to implement one of those features: the Option
type.
Putting stuff in boxes
To start, let’s create a Container
type that holds stuff. It can hold any type of value (a number, an object, etc.), so we will define it using a generic type (T
):
type Container<T> = { _value: T };
Let’s create a function that puts a value into our neat little container:
const container = <T>(x: T): Container<T> => ({ _value: x });
And here’s a few examples of how we could use our new type:
const twoContainer = container(2);
// type: Container<number>
// value: { _value: 2 }
const fooContainer = container("foo");
// type: Container<string>
// value: { _value: "foo" }
Running functions on wrapped values
Now what if we wanted to run a function on the value inside our container? For example, let’s say we have a function that returns the length of a string:
const strLen = (x: string) => x.length;
We can’t just call this function on a Container<string>
because it expects a plain ol’ string
type. To solve this, let’s create a function that let’s us run an operation on a value inside a Container
.
I’m going to define the function type first, then we can write the implementation afterwards (this approach is sometimes called “type-driven development”):
type ContainerMap = <T, U>(
fn: (x: T) => U,
c: Container<T>
) => Container<U>
Let’s break down what’s going on with this type definition:
- We define a type for a function that will use two generic types:
T
andU
. - The first parameter for our function is another function (
fn
):- That function takes any value of type
T
and returns a value of typeU
. - If we used
strLen
here, theT
would bestring
(the input string) and theU
would be anumber
(the string’s length).
- That function takes any value of type
- The second parameter is a
Container
with a typeT
in it. - The result of running all this is a new
Container
that holds the result type of thefn
.
To put it another way, it’s a function that takes a box with something in it and returns a box with something else in it (note: it’s possible for both things (T
and U
) to be the same type).
Despite the type complexities, the implementation is fairly straightforward:
const cMap: ContainerMap = (fn, c) => container(fn(c._value));
Groovy. Let’s try out our fancy new mapping function:
const nameContaier = container("Zack");
// type: Container<string>
// value: { _value: "Zack" }
const nameLenContainer = cMap(strLen, nameContainer);
// type: Container<number>
// value: { _value: 4 }
This probably seems like a lot of boilerplate. We’re wrapping values in a context (an object), but that context doesn’t really add anything extra for us.
So value maybe?
Rather than have a box that always has a value, what if we had a box that might have a value and might not. Schrödinger’s Container
.
To achieve this, we need a few new types:
type Option<T> = Some<T> | None;
type Some<T> = { _tag: "some"; _value: T };
type None = { _tag: "none" };
Our top-level type is called Option
and, like Container
it takes in a generic type (T
). An Option
can either contain a value (Some
) or not contain a value (None
). We’re using a tagged union type (_tag
) to ensure typescript knows what sub-type we’re talking about.
Since all None
types are the same, we can define a constant for it. We use never
as the generic type to signal that any Option
s that are none None
can not have a value. It might seem unintuitive, but this helps typescript give us better hints.
const none: Option<never> = { _tag: "none" };
Lastly, we need a function to put stuff into our new Option
box. With Container
we created a fairly straightforward container
function with a single type signature. For Option
, we need something a little more complex:
function option(x: null | undefined): None;
function option<T>(x: T): Some<T>;
function option<T>(x: T): Option<T> {
return x == null ? none : { _tag: "some", _value: x };
}
Let’s review what’s going on here:
- We create a function called
option
with a few different type signatures. - If a user passes a
null
orundefined
value to this function, it always returns aNone
. - If a user passes a different type of value, this will return a
Some
with that value type. - The function implementation checks if the value provided is
==
tonull
. By only using weak equality (==
instead of===
), this will be true fornull
andundefined
.
Now that we’ve done the necessary setup, let’s take our new type out for a spin:
const twoOption = option(2);
// type: Some<number>
// value: { _tag: "some", _value: 2 }
const fooOption = option("foo");
// type: Some<string>
// value: { _tag: "some", _value: "foo" }
const badOption = option(undefined);
// type: None
// value: { _tag: "none" }
Mapping an <Option>
Like Container
we probably want to run a function on the value inside our Option
type. This is more challenging now, since there may not be a value inside our type.
For convenience, let’s create a function to check if an Option
is a None
.
const isNone = <T>(o: Option<T>): o is None => o._tag === "none";
The o is None
bit is another trick to help typescript appropriately narrow the type. Type predicates are outside the scope of this post. The important thing to know that this helps Typescript provide more accurate results.
We can then use this to make our mapping function. Like with our option
function, we’re going to use an overloaded function signature.
function oMap<T, U>(fn: (x: T) => U, o: None): None;
function oMap<T, U>(fn: (x: T) => U, o: Option<T>): Some<U>;
function oMap<T, U>(fn: (x: T) => U, o: Option<T>): Option<U> {
return isNone(o) ? none : option(fn(o._value));
}
- If we call this with a
None
, it doesn’t run the provided function, it just returnsNone
. - If we call this with a
Some
, we run the provided function on our value and wrap the results back into anOption
(which could beNone
orSome
).
Here’s our new mapping function in practice:
const nameOption = option("Zack");
// type: Option<string>
// value: { _tag: "some", _value: "Zack" }
const nameLenOption = oMap(strLen, nameOption);
// type: Option<number>
// value: { _tag: "some", _value: 4 }
const badOption = option(undefined);
// type: None
// value: { _tag: "none" }
const badLenOption = oMap(strLen, badOption);
// type: None
// value: { _tag: "none" }
Notice how we passed badOption
to strLen
, but it didn’t blow up? This is a very powerful pattern. Let’s see how this could be used in a real(ish)-world scenario.
Maybe users?
Pretend that we have function that returns a list of users from a database:
type User = { id: number; name: string };
const getUsers = (): Users[] => { /* ... */ };
We would like to get the first user’s name. Our team has two functions for this purpose:
// Get the first element in an array
const head = <T>(xs: T[]): T => xs[0];
// Get a user name
const getName = (x: User) => x.name;
Should be no problem right? Let’s see how this works on the “happy path”:
const users = getUsers();
// type: User[]
// value: [ { id: 1, name: "bob" }, { id: 2, name: "alice" } ]
const firstUser = head(users);
// type: User
// value: { id: 1, name: "bob" }
const firstUserName = getName(firstUser);
// type: string
// value: "bob"
But what if getUsers
returns an empty array? Typescript wouldn’t complain about that, since []
still satisfies the Users[]
type:
const users = getUsers();
// type: User[]
// value: []
const firstUser = head(users);
// type: User <-- yikes!
// value: undefined
const firstUserName = getName(firstUser);
// type: string <-- double yikes!
// TypeError: Cannot read properties of undefined (reading 'name')
As you can see, Typescript is unable to determine the correct types and happily lets us code ourselves into a runtime exception. Let’s see how Option
can help us out.
Here’s the “happy path”:
const users = getUsers();
// type: User[]
// value: [ { id: 1, name: "bob" }, { id: 2, name: "alice" } ]
const firstUser = option(head(users));
// type: Option<User>
// value: { _tag: "some", _value: { id: 1, name: "bob" } }
const firstUserName = oMap(getName, firstUser);
// type: Option<string>
// value: { _tag: "some", _value: "bob" }
const firstUserNameLen = oMap(strLen, firstUserName);
// type: Option<number>
// value: { _tag: "some", _value: 3 }
And here’s the path that previously failed:
const users = getUsers();
// type: User[]
// value: []
const firstUser = option(head(users));
// type: Option<User>
// value: { _tag: "none" }
const firstUserName = oMap(getName, firstUser);
// type: Option<string>
// value: { _tag: "none" }
const firstUserNameLen = oMap(strLen, firstUserName);
// type: Option<number>
// value: { _tag: "none" }
Some really cool things happening here. We’ve made calls to head
, getName
, and I also snuck in a call to strLen
. We didn’t need to modify these functions at all and we can still chain these calls without writing a single if
statement in our code. If any of these calls result in a null
or undefined
, then any subsequent function calls get ignored.
At some point you might feel the need to pull the value out of the Option
wrapper. In some languages (e.g. Rust), there are functions that “unwrap” the value for use. I posit that this might not actually be necessary, but this post is already getting long, so I’ll leave that as an exercise for the reader.
For the jargon nerds
I’ve tried my best to avoid some functional-programming terms to get the point across. For those curious, here are some technical terms that we could have used:
Container
andOption
are examples of algebraic data types.- The way we’ve written our types means that, with the addition of the mapping function, they are also functors.
Container
is often called theIdentity
functor, and has application in more advanced functional programming topics.Option
,Some
, andNone
are also sometimes referred to asMaybe
,Just
, andNothing
.
How I’ve implemented these types is simplified for the purpose of the article. With a little more work, they could be more easily composed, pointed, and turned into monads.
If you wanted to pull in a library to start using something like Option
without writing it from scratch, consider the following options:
Option
in the typescript-first fp-ts libraryMaybe
in the Fantasy Land compatible version of RamdaMaybe
from Folktale which makes more use of classes in it’s approach
For the future
This is my longest post to date, but I find this topic very interesting. I plan on doing a follow-up post explaining the Either
type, which is like Option
except it holds two values: a failure value and a success value. This is helpful for explaining why your value is non-existent.
I would also love to cover functional composition and currying in typescript. Here’s how that last code example could look with a few more functional programming tools:
const getFirstUserNameLen = compose(
map(srLen),
map(prop('name')),
map(head)
option,
);
const happyUsers = getUsers();
// type: User[]
// value: [ { id: 1, name: "bob" }, { id: 2, name: "alice" } ]
const firstUserNameLen = getFirstUserNameLen(happyUsers);
// type: Option<number>
// value: { _tag: "some", _value: 3 }
const unhappyUsers = getUsers();
// type: User[]
// value: []
const firstUserNameLen = getFirstUserNameLen(unhappyUsers);
// type: Option<number>
// value: { _tag: "none" }
Stay tuned!