<--

Rust-like Enums in TypeScript

One of the nicest things about Rust is handling nulls with Option and error states with Result. Because Rust has good pattern matching, handling these cases becomes trivial as the complier forces you to handle all the cases of an enum in a match expression.

Because TypeScript lacks pattern matching, we have to lean on the type system in a different way to get similar semantics. For Option, we start with defining the two different states and the union type that represents the Option:

export interface Some<T> {
    tag: "some"
    value: T
}

export interface None {
    tag: "none"
}

export type Option<T> = Some<T> | None

Users should only ever deal with Option<T>, which represents a value or null. We’ll supply convenience methods later for creating Some<T>/None instances.

The tag field allows typescript to do a primitive form of pattern matching in a switch expression. It’s also best practice when defining union types so that users can otherwise test for which variant they are recieving.

However, because we want match like semantics, we’ll define things a little different. First, we have to implement our interfaces for Some<T> and None:

export namespace Option {
    class Some<T> implements Some<T> {
        public tag: "some" = "some"
        public value: T 
    
        public constructor(val: T) {
            this.value = val
        }

        public toString() {
            return `Some{ ${this.value} }`
        }
    }
    
    class None implements None {
        public tag: "none" = "none"
 
        public toString() {
            return `None { }`
        }
    }

Using a namespace helps us avoid conflicts with the interfaces defined for our variants. However, these variants should never be constructed directly – instead we provide the following helpers:

export function none(): None {
    return new None()
}

export function of<T>(val: T): Option<T> {
    if (val === null || val === undefined) {
        return Option.none()
    }
    
    return new Some(val)
}

However, this isn’t very useful. While the value field in Some is public, we really shouldn’t encourage users to directly access these values. We want to force them to handle both variants. As such, we’ll need an interface for match:

interface Matchable<T> {
    match<E>(fns: { some: (t: T) => E, none: () => E }): E;
}

This interface defines a single method which takes an object with two methods representing each of our variants, one of which will be called depending on the state of the option and produces any value defined by the type parameter E.

Unfortunately, actually implementing this interface poses a few problems.

First, we can try having each variant implement the interface directly:

export interface Some<T> extends Matchable<T> {
    tag: "some"
    value: T
}

export interface None extends Matchable<never>{
    tag: "none"
}

However, the type parameter T on the interface poses a problem for None: if the none or nil variant can never produce a value, it doesn’t make sense to be parameterized over T. In truth, None needs implements Matchable<never>, because the user should never be able to get a T out of it – we will always call the “none” handler in the match.

This is problematic. If each variant implements Matchable directly, TypeScript produces an error when trying to call match on an instance of Option:

let maybeNumber = Option.of(1)
maybeNumber.match({
    some(num) {
        // ...
    },
    none() {
        // ...
    }
}) // <--- ERROR!
[ts] Cannot invoke an expression whose type lacks a call signature. Type '(<E>(fns: { some: (t: never) => E; none: () => E; }) => E) | (<E>(fns: { some: (t: number) => E; none: () => E; }) => E)' has no compatible call signatures. [2349]

We cannot call match because typescript does not know how to call it. More sepcifically, the implementation of some for the None variant has never in its signature, and a method with a parameter that is never can never be called.

never is the “bottom” type, which means that it cannot be represented. It exists at the level of the type system and cannot have a concrete runtime value. Because a never cannot be instantiated, a function returning never is expected to diverge, i.e. never return, by throwing an exception or exiting.

Using never as a paramater, like we’re doing, is a bit more confusing. A function with a never parameter cannot be called, because it is impossible to provide a concerete instance of never with which to call the function.

An uncallable function might not initially seem very useful, but in our case it ensures that the None variant can never produce a value. Because a function with the signature some(t: never) can never be called, this enfroces the invariant that the implementation for None can only call the none method.

However, even though we cannot use the some method in the None implementation, when calling match via a reference to a Some<T> | None, TypeScript infers the type (Some<T> & Matchable<T>) | (None & Matchable<never>), or for purposes of our match method. In other words, we could be dealing with a Matchable<never>.

Here’s the solution to our problems:

export type Option<T> = (Some<T> | None) & Matchable<T>

This exposes another weird thing about never – it is assignable to any type. As the bottom type, never is a subtype of every type. I lack the type theory knowledge to fully understand the implications of this, but it presents some interesting behavior:

let a: never
let b: number
let c: string

b = c // Type 'string' is not assignable to type 'number'.ts(2322)
b = a // Fine
c = a // Fine

This assignment rule also applies to a parameterized interface:

interface Matchable<T> {}
let matchableNumber: Matchable<number> = {}
let matchableNever: Matchable<never> = {}

matchableNumber = matchableNever

In other words, even though our implementation for None implements Matchable<never>, because Matchable<never> is assignable to Matchable<T>, our None implementation satisfies the intersection & Matchable<T> for Option.

Consequently, our Option type does what we’d expect:

maybeNumber.match({
    some(num) {
        console.log(num) // Prints the number
    },
    none() {
        console.log("none")
    }
})

Implementing a Result type is an excercise left to the reader, but works very similarly. The obvious downside to this approach in TypeScript is that we are manually implementing pattern matching for each enum type we want to create. We have to manually implement all the utility functions (map, filter, etc.) as well.

Written on Mar 8, 2019.