Introduction

When I started working on large‑scale APIs, I often found myself juggling promises and try/catch blocks. Every service call could either resolve with data or reject with an error, and the type system offered little guidance on how to chain operations safely. I needed a way to express success and failure as first‑class types, so the compiler could guide me toward correct usage. The solution I adopted is a tiny Result monad that models either outcome while keeping the types crisp and immutable.

The Problem with Promises

Native Promise<T> forces you to separate handling of success and failure. If you try to .map over a promise that might reject, you either need try/catch or catch chains, which quickly become messy. Moreover, the type of the resolved value is T, while the rejected value is any (or a broad unknown

Consider a typical fetch routine:

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error(response.statusText);
  return response.json();
}

// Usage
fetchUser('123')
  .then(user => console.log(user.name))
  .catch(err => console.error('Failed:', err);

Here the error type is opaque, and any helper that expects a User must guard against the possibility of an error bubbling up. The Result monad solves this by wrapping both outcomes in a single type.

Introducing the Result Monad

A Result is a discriminated union with two variants: Ok for success and Err for failure. By defining a single generic type Result<T, E>, we can write functions that work uniformly on both branches. The pattern is small, immutable, and plays nicely with TypeScript’s type inference.

Below is a production‑ready implementation that includes construction helpers, mapping, flattening, and a match method for exhaustive handling.

// src/types/result.ts

type Result<T, E = string> =
  | { readonly success: true; readonly value: T }
  | { readonly success: false; readonly error: E };

/**
 * Wrap a value as a successful result.
 * @param value The success payload.
 */
function ok<T, E>(value: T): Result<T, E> {
  return { success: true, value };
}

/**
 * Wrap an error as a failure result.
 * @param error The error payload (defaults to a string).
 */
function err<T, E = string>(error: E): Result<T, E> {
  return { success: false, error };
}

/**
 * Map over the success side of a Result.
 * If the result is an error, propagate unchanged.
 */
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.success ? { success: true, value: fn(result.value) } : result;
}

/**
 * Flatten nested Results (e.g., Result<Result<U, E>, E>).
 */
function flatten<T, E>(result: Result<Result<T, E>, E>): Result<T, E> {
  return result.success ? result.value : result;
}

/**
 * Chain operations that also return a Result.
 * If the current result is an error, the chain stops.
 */
function flatMap<T, U, E>(result: Result<T, E>, fn: (value: T) => Result<U, E>): Result<U, E> {
  return result.success ? fn(result.value) : result;
}

/**
 * Exhaustively handle a Result. The type system guarantees that both branches are covered.
 */
function match<T, U, E>(
  result: Result<T, E>,
  onSuccess: (value: T) => U,
  onFailure: (error: E) => U
): U {
  return result.success ? onSuccess(result.value) : onFailure(result.error);
}

export { ok, err, map, flatMap, flatten, match, type Result };