An Option type in Typescript

2023-10-06

#typescript #fp 

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:

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 Options 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:

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));
}

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:

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:

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!