How do you emulate ADTs and pattern matching in TypeScript?

4.2k Views Asked by At

Unfortunately, as of 0.9.5, TypeScript doesn't (yet) have algebraic data types (union types) and pattern matching (to destructure them). What's more, it doesn't even support instanceof on interfaces. Which pattern do you use to emulate these language features with maximal type safety and minimal boilerplate code?

6

There are 6 best solutions below

1
On BEST ANSWER

TypeScript 1.4 adds union types and type guards.

1
On

To answer

it doesn't even support instanceof on interfaces.

Reason is type erasure. Interfaces are a compile type construct only and don't have any runtime implications. However you can use instanceof on classes e.g. :

class Foo{}
var x = new Foo();
console.log(x instanceof Foo); // true
1
On

I went with the following Visitor-like pattern, inspired by this and this (in the example, a Choice can be Foo or Bar):

interface Choice {
    match<T>(cases: ChoiceCases<T>): T;
}

interface ChoiceCases<T> {
    foo(foo: Foo): T;
    bar(bar: Bar): T;
}

class Foo implements Choice {

    match<T>(cases: ChoiceCases<T>): T {
        return cases.foo(this);
    }

}

class Bar implements Choice {

    match<T>(cases: ChoiceCases<T>): T {
        return cases.bar(this);
    }

}

Usage:

function getName(choice: Choice): string {
    return choice.match({
        foo: foo => "Foo",
        bar: bar => "Bar",
    });
}

The matching itself is expressive and type-safe, but there's lot of boilerplate to write for the types.

1
On

Example to illustrate the accepted answer:

enum ActionType { AddItem, RemoveItem, UpdateItem }
type Action =
    {type: ActionType.AddItem, content: string} |
    {type: ActionType.RemoveItem, index: number} |
    {type: ActionType.UpdateItem, index: number, content: string}

function dispatch(action: Action) {
    switch(action.type) {
    case ActionType.AddItem:
        // now TypeScript knows that "action" has only "content" but not "index"
        console.log(action.content);
        break;
    case ActionType.RemoveItem:
        // now TypeScript knows that "action" has only "index" but not "content"
        console.log(action.index);
        break;
    default:
    }
}
0
On

Here's an alternative to the very good answer by @thSoft. On the plus side, this alternative

  1. has potential interoperability with raw javascript objects on the form { type : string } & T, where the shape of T depends on the value of type,
  2. has substantially less per-choice boilerplate;

on the negative side

  1. does not enforce statically that you match all cases,
  2. does not distinguish between different ADTs.

It looks like this:

// One-time boilerplate, used by all cases. 

interface Maybe<T> { value : T }
interface Matcher<T> { (union : Union) : Maybe<T> }

interface Union { type : string }

class Case<T> {
  name : string;
  constructor(name: string) {
    this.name = name;
  }
  _ = (data: T) => ( <Union>({ type : this.name, data : data }) )
  $ =
    <U>(f:(t:T) => U) => (union : Union) =>
        union.type === this.name
          ? { value : f((<any>union).data) }
          : null
}

function match<T>(union : Union, destructors : Matcher<T> [], t : T = null)
{
  for (const destructor of destructors) {
    const option = destructor(union);
    if (option)
      return option.value;
  }
  return t;
}

function any<T>(f:() => T) : Matcher<T> {
  return x => ({ value : f() });
}

// Usage. Define cases.

const A = new Case<number>("A");
const B = new Case<string>("B");

// Construct values.

const a = A._(0);
const b = B._("foo");

// Destruct values.

function f(union : Union) {
  match(union, [
    A.$(x => console.log(`A : ${x}`))
  , B.$(y => console.log(`B : ${y}`))
  , any (() => console.log(`default case`))
  ])
}

f(a);
f(b);
f(<any>{});
0
On

This is an old question, but maybe this will still help someone:

Like @SorenDebois's answer, this one has half of the per-case boilerplate as @theSoft's. It is also more encapsulated than @Soren's. Additionally, this solution has type safety, switch-like behavior, and forces you to check all cases.

// If you want to be able to not check all cases, you can wrap this type in `Partial<...>`
type MapToFuncs<T> = { [K in keyof T]: (v: T[K]) => void }
// This is used to extract the enum value type associated with an enum. 
type ValueOfEnum<_T extends Enum<U>, U = any> = EnumValue<U>

class EnumValue<T> {
  constructor(
    private readonly type: keyof T,
    private readonly value?: T[keyof T]
  ) {}

  switch(then: MapToFuncs<T>) {
    const f = then[this.type] as (v: T[keyof T]) => void
    f(this.value)
  }
}

// tslint:disable-next-line: max-classes-per-file
class Enum<T> {
  case<K extends keyof T>(k: K, v: T[K]) {
    return new EnumValue(k, v)
  }
}

Usage:

// Define the enum. We only need to mention the cases once!
const GameState = new Enum<{
  NotStarted: {}
  InProgress: { round: number }
  Ended: {}
}>()

// Some function that checks the game state:
const doSomethingWithState = (state: ValueOfEnum<typeof GameState>) => {
    state.switch({
      Ended: () => { /* One thing */ },
      InProgress: ({ round }) => { /* Two thing with round */ },
      NotStarted: () => { /* Three thing */ },
    })
}

// Calling the function
doSomethingWithState(GameState.case("Ended", {}))

The one aspect here that is really not ideal is the need for ValueOfEnum. In my application, that was enough for me to go with @theSoft's answer. If anyone knows how to compress this, drop a comment below!