Building a Type‑Safe Result Monad in TypeScript for Cleaner Error Handling
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). This asymmetry makes it hard to write reusable utilities that work on either side of the operation.
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 };