Oscar te Giffel®
← Writing

Effective Error Handling with Result Types in TypeScript.

TypeScript has no built-in way to represent "this function might fail" in the type system. Here's how to build one.

cover

TypeScript doesn’t have a built-in way to say “this function might fail” at the type level. You can throw errors, sure, but the caller has no idea from the function signature. The Result type fixes that.

The Result Type

A Result wraps the outcome of a function that can succeed or fail. Two things it needs to do:

  1. Let you unwrap the underlying value.
  2. Let you build on top of it without unwrapping first.

Here’s the implementation:

enum ResultKind {
  OK = "Ok",
  ERR = "Err",
}

export type Result<T, E> = Ok<T> | Err<E>;

interface ResultBase<A, E> {
  kind: ResultKind;
  map<B>(fn: (_: A) => B): Result<B, E>;
  bind<B>(fn: (_: A) => Result<B, E>): Result<B, E>;
  match<B>(obj: { ok: (_: A) => B; err: (_: E) => B }): B;
}

export type Ok<A> = Readonly<ResultBase<A, never> & { kind: ResultKind.OK; value: A }>;

export function ok<A>(a: A): Ok<A> {
  return {
    kind: ResultKind.OK,
    value: a,
    map(fn) {
      return ok(fn(this.value));
    },
    bind(fn) {
      return fn(this.value);
    },
    match({ ok }) {
      return ok(this.value);
    },
  };
}

export type Err<E> = Readonly<ResultBase<never, E> & { kind: ResultKind.ERR; error: E }>;

export function err<E>(e: E): Err<E> {
  return {
    kind: ResultKind.ERR,
    error: e,
    map() {
      return this;
    },
    bind() {
      return this;
    },
    match({ err }) {
      return err(this.error);
    },
  };
}

Using the Result Type

function divide(a: number, b: number): Result<number, string> {
  return b === 0 ? err("Division by zero") : ok(a / b);
}

// Example usage
const result1 = divide(10, 2);
const result2 = divide(8, 0);

// Pattern matching
result1.match({
  ok: (value) => console.log(`Result: ${value}`),
  err: (error) => console.error(`Error: ${error}`),
});

result2.match({
  ok: (value) => console.log(`Result: ${value}`),
  err: (error) => console.error(`Error: ${error}`),
});

Why Bother

This shares some properties with monads, though it’s a simplified version. The main wins:

  1. The return type tells you an error might happen. No more guessing.
  2. TypeScript forces you to handle both cases. You can’t ignore the error path.
  3. Results chain together. map and bind let you compose operations without unwrapping at every step.

It would be ideal to have this at the language level, but this gets us most of the way there.

I’d encourage building this yourself to understand how it works, but if you want a battle-tested version, check out ts-results.