Implementing the Result Pattern in TypeScript

· 5 min read
typescript patterns error-handling

What We’re Building

A Result type that makes error handling explicit. No more thrown exceptions that might be caught-or might not. Inspired by Rust’s Result type, adapted for TypeScript.

Prerequisites

  • TypeScript 5+
  • Understanding of discriminated unions
  • Familiarity with generics

The Approach

  1. Define the Result type
  2. Create constructor functions
  3. Add utility methods
  4. Handle async operations
  5. Chain operations

Step 1: Define the Type

// lib/result.ts
type Ok<T> = {
  readonly ok: true;
  readonly value: T;
};

type Err<E> = {
  readonly ok: false;
  readonly error: E;
};

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

Step 2: Constructor Functions

export function ok<T>(value: T): Ok<T> {
  return { ok: true, value };
}

export function err<E>(error: E): Err<E> {
  return { ok: false, error };
}

export function fromThrowable<T, E = Error>(
  fn: () => T,
  mapError?: (e: unknown) => E
): Result<T, E> {
  try {
    return ok(fn());
  } catch (e) {
    return err(mapError ? mapError(e) : (e as E));
  }
}

export async function fromPromise<T, E = Error>(
  promise: Promise<T>,
  mapError?: (e: unknown) => E
): Promise<Result<T, E>> {
  try {
    return ok(await promise);
  } catch (e) {
    return err(mapError ? mapError(e) : (e as E));
  }
}

Step 3: Type Guards

export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
  return result.ok;
}

export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
  return !result.ok;
}

Step 4: Utility Functions

export function map<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => U
): Result<U, E> {
  if (result.ok) {
    return ok(fn(result.value));
  }
  return result;
}

export function mapErr<T, E, F>(
  result: Result<T, E>,
  fn: (error: E) => F
): Result<T, F> {
  if (!result.ok) {
    return err(fn(result.error));
  }
  return result;
}

export function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  if (result.ok) {
    return fn(result.value);
  }
  return result;
}

export function unwrap<T, E>(result: Result<T, E>): T {
  if (result.ok) {
    return result.value;
  }
  throw result.error;
}

export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
  if (result.ok) {
    return result.value;
  }
  return defaultValue;
}

export function unwrapOrElse<T, E>(
  result: Result<T, E>,
  fn: (error: E) => T
): T {
  if (result.ok) {
    return result.value;
  }
  return fn(result.error);
}

Step 5: Match Function

export function match<T, E, U>(
  result: Result<T, E>,
  handlers: {
    ok: (value: T) => U;
    err: (error: E) => U;
  }
): U {
  if (result.ok) {
    return handlers.ok(result.value);
  }
  return handlers.err(result.error);
}

Step 6: Combine Results

export function all<T extends readonly Result<unknown, unknown>[]>(
  results: T
): Result<
  { [K in keyof T]: T[K] extends Result<infer V, unknown> ? V : never },
  T[number] extends Result<unknown, infer E> ? E : never
> {
  const values: unknown[] = [];

  for (const result of results) {
    if (!result.ok) {
      return result as any;
    }
    values.push(result.value);
  }

  return ok(values as any);
}

export function any<T, E>(results: Result<T, E>[]): Result<T, E[]> {
  const errors: E[] = [];

  for (const result of results) {
    if (result.ok) {
      return result;
    }
    errors.push(result.error);
  }

  return err(errors);
}

Step 7: Practical Usage

// services/user-service.ts
import { Result, ok, err, fromPromise } from '../lib/result';

interface User {
  id: string;
  email: string;
}

type UserError =
  | { type: 'not_found'; id: string }
  | { type: 'validation'; message: string }
  | { type: 'database'; cause: Error };

async function findUser(id: string): Promise<Result<User, UserError>> {
  if (!id.match(/^[0-9a-f-]{36}$/)) {
    return err({ type: 'validation', message: 'Invalid user ID format' });
  }

  const result = await fromPromise(
    db.users.findUnique({ where: { id } }),
    (e): UserError => ({ type: 'database', cause: e as Error })
  );

  if (!result.ok) {
    return result;
  }

  if (!result.value) {
    return err({ type: 'not_found', id });
  }

  return ok(result.value);
}

async function updateEmail(
  userId: string,
  email: string
): Promise<Result<User, UserError>> {
  const userResult = await findUser(userId);

  if (!userResult.ok) {
    return userResult;
  }

  return fromPromise(
    db.users.update({
      where: { id: userId },
      data: { email },
    }),
    (e): UserError => ({ type: 'database', cause: e as Error })
  );
}

Step 8: Controller Usage

// controllers/user-controller.ts
import { match } from '../lib/result';
import { findUser, UserError } from '../services/user-service';

async function getUser(req: Request): Promise<Response> {
  const result = await findUser(req.params.id);

  return match(result, {
    ok: (user) => Response.json(user),
    err: (error) => {
      switch (error.type) {
        case 'not_found':
          return Response.json({ error: 'User not found' }, { status: 404 });
        case 'validation':
          return Response.json({ error: error.message }, { status: 400 });
        case 'database':
          console.error(error.cause);
          return Response.json({ error: 'Internal error' }, { status: 500 });
      }
    },
  });
}

Step 9: Testing

import { describe, it, expect } from 'vitest';
import { ok, err, map, flatMap, all } from './result';

describe('Result', () => {
  it('maps over ok values', () => {
    const result = ok(5);
    const mapped = map(result, (x) => x * 2);

    expect(mapped).toEqual({ ok: true, value: 10 });
  });

  it('short-circuits on error', () => {
    const result = err('failed');
    const mapped = map(result, (x: number) => x * 2);

    expect(mapped).toEqual({ ok: false, error: 'failed' });
  });

  it('combines results with all', () => {
    const results = [ok(1), ok(2), ok(3)];
    const combined = all(results);

    expect(combined).toEqual({ ok: true, value: [1, 2, 3] });
  });
});

The Result

  • Explicit error handling at type level
  • No forgotten try/catch blocks
  • Composable with map/flatMap
  • Pattern matching for exhaustive handling
  • TypeScript inference throughout

What I’d Do Differently

Start with Result from the beginning. Retrofitting it into a codebase that throws everywhere is painful. Build new projects with Result from day one.

The Result pattern isn’t about being clever, it’s about making errors visible. If you can forget to handle an error, you will.

Available for rescue and re-platforming work

I take over platforms that already exist and are in trouble. Node, TypeScript, React and Laravel, mostly in regulated or high-traffic environments. If something needs rescuing, re-platforming or finishing, my full history is on the CV.

Related Posts

Comments