Implementing the Result Pattern in TypeScript
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
- Define the Result type
- Create constructor functions
- Add utility methods
- Handle async operations
- 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.