Monadic Error Handling in TypeScript: Result and Option Types
Monadic Error Handling in TypeScript: Result and Option Types
Moving beyond try/catch — implementing Railway Oriented Programming patterns that make error handling explicit, composable, and impossible to ignore.
The Problem with Exceptions
Exceptions are invisible control flow. They create hidden exit points in functions, make error handling optional, and provide no compile-time guarantees about what can fail:
// What can throw here? Everything.
async function processOrder(orderId: string): Promise<Order> {
const order = await fetchOrder(orderId); // Network error? 404?
const validated = validateOrder(order); // Validation error?
const inventory = await checkInventory(order); // Service down?
const payment = await chargeCustomer(order); // Card declined?
const shipped = await shipOrder(order); // Shipping API error?
return shipped;
}
// Caller has no idea what to catch
try {
await processOrder(id);
} catch (e) {
// What type is e? unknown. What errors are possible? Who knows.
console.error("Something went wrong");
}
The problems compound:
┌─────────────────────────────────────────────────────────────────┐
│ Exception-Based Control Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ function a() ─────┐ │
│ │ throw │
│ function b() ◄────┘ │
│ │ │
│ │ (no catch) │
│ ▼ │
│ function c() ─────┐ │
│ │ throw (different error) │
│ function d() ◄────┘ │
│ │ │
│ │ (no catch) │
│ ▼ │
│ function e() │
│ │ │
│ │ catch (error: unknown) │
│ │ ├── Is this from a()? b()? c()? d()? │
│ │ ├── What type is it? │
│ │ └── What errors are even possible? │
│ ▼ │
│ // Defensive programming: catch everything, handle nothing │
│ │
└─────────────────────────────────────────────────────────────────┘
Railway Oriented Programming
The metaphor is simple: computations run on two parallel tracks—success and failure. Each operation either continues on the success track or switches to the failure track. Once on the failure track, subsequent operations are bypassed:
┌─────────────────────────────────────────────────────────────────┐
│ Railway Oriented Programming │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Success Track ═══════════════════════════════════════════════ │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ ═════│ A │══════│ B │══════│ C │══════│ D │═════► Ok │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │
│ │ fail │ fail │ fail │ fail │
│ ▼ ▼ ▼ ▼ │
│ ────────●────────────●────────────●────────────●────────► Err │
│ │
│ Failure Track ─────────────────────────────────────────────────│
│ │
│ Example: If B fails, C and D are skipped │
│ │
│ ┌─────┐ ┌─────┐ │
│ ═════│ A │══════│ B │ │
│ └─────┘ └──┬──┘ │
│ │ fail │
│ ▼ │
│ ─────────────────────●──────────────────────────────────► Err │
│ │
└─────────────────────────────────────────────────────────────────┘
This gives us:
- Explicit failure paths — functions declare what can go wrong
- Composability — chain operations without nested try/catch
- Type safety — compiler enforces error handling
- Referential transparency — no hidden control flow
Implementing the Result Type
Let's build a production-grade Result type from first principles:
// result.ts
/**
* Discriminated union representing either success (Ok) or failure (Err)
*/
export type Result<T, E> = Ok<T, E> | Err<T, E>;
/**
* Success case - contains the value
*/
export class Ok<T, E> {
readonly _tag = 'Ok' as const;
constructor(readonly value: T) {}
isOk(): this is Ok<T, E> {
return true;
}
isErr(): this is Err<T, E> {
return false;
}
/**
* Transform the success value
*/
map<U>(fn: (value: T) => U): Result<U, E> {
return new Ok(fn(this.value));
}
/**
* Transform the error value (no-op for Ok)
*/
mapErr<F>(_fn: (error: E) => F): Result<T, F> {
return new Ok(this.value);
}
/**
* Chain operations that return Results
*/
flatMap<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
return fn(this.value);
}
/**
* Alias for flatMap (Haskell terminology)
*/
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
return this.flatMap(fn);
}
/**
* Apply a function wrapped in a Result
*/
ap<U>(resultFn: Result<(value: T) => U, E>): Result<U, E> {
return resultFn.map(fn => fn(this.value));
}
/**
* Provide fallback for error case (no-op for Ok)
*/
orElse<F>(_fn: (error: E) => Result<T, F>): Result<T, F> {
return new Ok(this.value);
}
/**
* Unwrap with default value
*/
unwrapOr(_defaultValue: T): T {
return this.value;
}
/**
* Unwrap with lazy default
*/
unwrapOrElse(_fn: (error: E) => T): T {
return this.value;
}
/**
* Unwrap or throw - use sparingly at boundaries
*/
unwrap(): T {
return this.value;
}
/**
* Pattern match on Result
*/
match<U>(patterns: { ok: (value: T) => U; err: (error: E) => U }): U {
return patterns.ok(this.value);
}
/**
* Execute side effect on success
*/
tap(fn: (value: T) => void): Result<T, E> {
fn(this.value);
return this;
}
/**
* Convert to Option, discarding error information
*/
ok(): Option<T> {
return some(this.value);
}
/**
* Convert error to Option (returns None for Ok)
*/
err(): Option<E> {
return none;
}
}
/**
* Failure case - contains the error
*/
export class Err<T, E> {
readonly _tag = 'Err' as const;
constructor(readonly error: E) {}
isOk(): this is Ok<T, E> {
return false;
}
isErr(): this is Err<T, E> {
return true;
}
map<U>(_fn: (value: T) => U): Result<U, E> {
return new Err(this.error);
}
mapErr<F>(fn: (error: E) => F): Result<T, F> {
return new Err(fn(this.error));
}
flatMap<U>(_fn: (value: T) => Result<U, E>): Result<U, E> {
return new Err(this.error);
}
andThen<U>(_fn: (value: T) => Result<U, E>): Result<U, E> {
return new Err(this.error);
}
ap<U>(_resultFn: Result<(value: T) => U, E>): Result<U, E> {
return new Err(this.error);
}
orElse<F>(fn: (error: E) => Result<T, F>): Result<T, F> {
return fn(this.error);
}
unwrapOr(defaultValue: T): T {
return defaultValue;
}
unwrapOrElse(fn: (error: E) => T): T {
return fn(this.error);
}
unwrap(): T {
throw new Error(`Called unwrap on Err: ${this.error}`);
}
match<U>(patterns: { ok: (value: T) => U; err: (error: E) => U }): U {
return patterns.err(this.error);
}
tap(_fn: (value: T) => void): Result<T, E> {
return this;
}
ok(): Option<T> {
return none;
}
err(): Option<E> {
return some(this.error);
}
}
// Smart constructors
export const ok = <T, E = never>(value: T): Result<T, E> => new Ok(value);
export const err = <E, T = never>(error: E): Result<T, E> => new Err(error);
Implementing the Option Type
Option represents a value that may or may not exist—replacing null/undefined with an explicit type:
// option.ts
export type Option<T> = Some<T> | None<T>;
export class Some<T> {
readonly _tag = 'Some' as const;
constructor(readonly value: T) {}
isSome(): this is Some<T> {
return true;
}
isNone(): this is None<T> {
return false;
}
map<U>(fn: (value: T) => U): Option<U> {
return new Some(fn(this.value));
}
flatMap<U>(fn: (value: T) => Option<U>): Option<U> {
return fn(this.value);
}
filter(predicate: (value: T) => boolean): Option<T> {
return predicate(this.value) ? this : none;
}
unwrapOr(_defaultValue: T): T {
return this.value;
}
unwrapOrElse(_fn: () => T): T {
return this.value;
}
unwrap(): T {
return this.value;
}
match<U>(patterns: { some: (value: T) => U; none: () => U }): U {
return patterns.some(this.value);
}
/**
* Convert to Result with provided error
*/
okOr<E>(error: E): Result<T, E> {
return ok(this.value);
}
/**
* Convert to Result with lazy error
*/
okOrElse<E>(fn: () => E): Result<T, E> {
return ok(this.value);
}
/**
* Zip with another Option
*/
zip<U>(other: Option<U>): Option<[T, U]> {
return other.map(u => [this.value, u] as [T, U]);
}
/**
* Flatten nested Option
*/
flatten<U>(this: Option<Option<U>>): Option<U> {
return this.value;
}
}
class NoneImpl<T> {
readonly _tag = 'None' as const;
isSome(): this is Some<T> {
return false;
}
isNone(): this is None<T> {
return true;
}
map<U>(_fn: (value: T) => U): Option<U> {
return none;
}
flatMap<U>(_fn: (value: T) => Option<U>): Option<U> {
return none;
}
filter(_predicate: (value: T) => boolean): Option<T> {
return none;
}
unwrapOr(defaultValue: T): T {
return defaultValue;
}
unwrapOrElse(fn: () => T): T {
return fn();
}
unwrap(): T {
throw new Error('Called unwrap on None');
}
match<U>(patterns: { some: (value: T) => U; none: () => U }): U {
return patterns.none();
}
okOr<E>(error: E): Result<T, E> {
return err(error);
}
okOrElse<E>(fn: () => E): Result<T, E> {
return err(fn());
}
zip<U>(_other: Option<U>): Option<[T, U]> {
return none;
}
}
// Singleton None instance
export type None<T> = NoneImpl<T>;
export const none: Option<never> = new NoneImpl<never>();
// Smart constructor
export const some = <T>(value: T): Option<T> => new Some(value);
// Create Option from nullable value
export const fromNullable = <T>(value: T | null | undefined): Option<T> =>
value == null ? none : some(value);
// Create Option from predicate
export const fromPredicate = <T>(
value: T,
predicate: (value: T) => boolean
): Option<T> => (predicate(value) ? some(value) : none);
Utility Functions for Composition
// result-utils.ts
import { Result, Ok, Err, ok, err } from './result';
import { Option, some, none, fromNullable } from './option';
/**
* Wrap a throwing function in a Result
*/
export function tryCatch<T, E = Error>(
fn: () => T,
onError: (error: unknown) => E = (e) => e as E
): Result<T, E> {
try {
return ok(fn());
} catch (error) {
return err(onError(error));
}
}
/**
* Wrap an async throwing function in a Result
*/
export async function tryCatchAsync<T, E = Error>(
fn: () => Promise<T>,
onError: (error: unknown) => E = (e) => e as E
): Promise<Result<T, E>> {
try {
return ok(await fn());
} catch (error) {
return err(onError(error));
}
}
/**
* Combine multiple Results - all must succeed
*/
export function all<T extends readonly Result<unknown, unknown>[]>(
results: T
): Result<
{ [K in keyof T]: T[K] extends Result<infer U, unknown> ? U : never },
T[number] extends Result<unknown, infer E> ? E : never
> {
const values: unknown[] = [];
for (const result of results) {
if (result.isErr()) {
return result as any;
}
values.push(result.value);
}
return ok(values) as any;
}
/**
* Try multiple Results in sequence, return first success
*/
export function any<T, E>(results: Result<T, E>[]): Result<T, E[]> {
const errors: E[] = [];
for (const result of results) {
if (result.isOk()) {
return result;
}
errors.push(result.error);
}
return err(errors);
}
/**
* Partition an array of Results
*/
export function partition<T, E>(
results: Result<T, E>[]
): { ok: T[]; err: E[] } {
const ok: T[] = [];
const err: E[] = [];
for (const result of results) {
result.match({
ok: (value) => ok.push(value),
err: (error) => err.push(error),
});
}
return { ok, err };
}
/**
* Sequence an array through a Result-returning function
*/
export function traverse<T, U, E>(
items: T[],
fn: (item: T) => Result<U, E>
): Result<U[], E> {
const results: U[] = [];
for (const item of items) {
const result = fn(item);
if (result.isErr()) {
return result as Result<U[], E>;
}
results.push(result.value);
}
return ok(results);
}
/**
* Async version of traverse
*/
export async function traverseAsync<T, U, E>(
items: T[],
fn: (item: T) => Promise<Result<U, E>>
): Promise<Result<U[], E>> {
const results: U[] = [];
for (const item of items) {
const result = await fn(item);
if (result.isErr()) {
return result as Result<U[], E>;
}
results.push(result.value);
}
return ok(results);
}
/**
* Parallel traverse with Result
*/
export async function traverseParallel<T, U, E>(
items: T[],
fn: (item: T) => Promise<Result<U, E>>
): Promise<Result<U[], E>> {
const results = await Promise.all(items.map(fn));
return all(results) as Result<U[], E>;
}
/**
* Do-notation style syntax using generators
*/
export function Do<T, E>(
generator: () => Generator<Result<unknown, E>, T, unknown>
): Result<T, E> {
const iterator = generator();
let state = iterator.next();
while (!state.done) {
const result = state.value as Result<unknown, E>;
if (result.isErr()) {
return err(result.error);
}
state = iterator.next(result.value);
}
return ok(state.value);
}
Typed Error Hierarchies
The power of Result comes from typed error channels. Define exhaustive error types:
// errors.ts
/**
* Base error structure with context
*/
interface BaseError {
readonly _tag: string;
readonly message: string;
readonly timestamp: Date;
readonly context?: Record<string, unknown>;
}
/**
* Domain-specific error types
*/
export type OrderError =
| OrderNotFoundError
| OrderValidationError
| InventoryError
| PaymentError
| ShippingError;
export interface OrderNotFoundError extends BaseError {
readonly _tag: 'OrderNotFoundError';
readonly orderId: string;
}
export interface OrderValidationError extends BaseError {
readonly _tag: 'OrderValidationError';
readonly violations: ValidationViolation[];
}
export interface ValidationViolation {
readonly field: string;
readonly message: string;
readonly value: unknown;
}
export interface InventoryError extends BaseError {
readonly _tag: 'InventoryError';
readonly sku: string;
readonly requested: number;
readonly available: number;
}
export interface PaymentError extends BaseError {
readonly _tag: 'PaymentError';
readonly code: PaymentErrorCode;
readonly transactionId?: string;
}
export type PaymentErrorCode =
| 'CARD_DECLINED'
| 'INSUFFICIENT_FUNDS'
| 'EXPIRED_CARD'
| 'FRAUD_DETECTED'
| 'PROCESSOR_ERROR';
export interface ShippingError extends BaseError {
readonly _tag: 'ShippingError';
readonly carrier: string;
readonly reason: string;
}
// Error constructors
export const orderNotFound = (orderId: string): OrderNotFoundError => ({
_tag: 'OrderNotFoundError',
message: `Order ${orderId} not found`,
orderId,
timestamp: new Date(),
});
export const validationError = (
violations: ValidationViolation[]
): OrderValidationError => ({
_tag: 'OrderValidationError',
message: `Validation failed: ${violations.map(v => v.field).join(', ')}`,
violations,
timestamp: new Date(),
});
export const inventoryError = (
sku: string,
requested: number,
available: number
): InventoryError => ({
_tag: 'InventoryError',
message: `Insufficient inventory for ${sku}: requested ${requested}, available ${available}`,
sku,
requested,
available,
timestamp: new Date(),
});
export const paymentError = (
code: PaymentErrorCode,
transactionId?: string
): PaymentError => ({
_tag: 'PaymentError',
message: `Payment failed: ${code}`,
code,
transactionId,
timestamp: new Date(),
});
export const shippingError = (
carrier: string,
reason: string
): ShippingError => ({
_tag: 'ShippingError',
message: `Shipping failed via ${carrier}: ${reason}`,
carrier,
reason,
timestamp: new Date(),
});
Real-World Example: Order Processing Pipeline
Now let's refactor the order processing with explicit error handling:
// order-service.ts
import { Result, ok, err, tryCatchAsync, traverse } from './result';
import {
OrderError,
orderNotFound,
validationError,
inventoryError,
paymentError,
shippingError,
ValidationViolation,
} from './errors';
interface Order {
id: string;
customerId: string;
items: OrderItem[];
shippingAddress: Address;
status: OrderStatus;
}
interface OrderItem {
sku: string;
quantity: number;
unitPrice: number;
}
interface Address {
street: string;
city: string;
country: string;
postalCode: string;
}
type OrderStatus = 'pending' | 'validated' | 'paid' | 'shipped' | 'delivered';
/**
* Fetch order - returns Result instead of throwing
*/
async function fetchOrder(orderId: string): Promise<Result<Order, OrderError>> {
const response = await fetch(`/api/orders/${orderId}`);
if (response.status === 404) {
return err(orderNotFound(orderId));
}
if (!response.ok) {
return err({
_tag: 'OrderNotFoundError',
message: `Failed to fetch order: ${response.statusText}`,
orderId,
timestamp: new Date(),
});
}
const order = await response.json();
return ok(order);
}
/**
* Validate order - pure function returning Result
*/
function validateOrder(order: Order): Result<Order, OrderError> {
const violations: ValidationViolation[] = [];
if (order.items.length === 0) {
violations.push({
field: 'items',
message: 'Order must have at least one item',
value: order.items,
});
}
for (const item of order.items) {
if (item.quantity <= 0) {
violations.push({
field: `items[${item.sku}].quantity`,
message: 'Quantity must be positive',
value: item.quantity,
});
}
if (item.unitPrice <= 0) {
violations.push({
field: `items[${item.sku}].unitPrice`,
message: 'Price must be positive',
value: item.unitPrice,
});
}
}
if (!order.shippingAddress.postalCode) {
violations.push({
field: 'shippingAddress.postalCode',
message: 'Postal code is required',
value: order.shippingAddress.postalCode,
});
}
if (violations.length > 0) {
return err(validationError(violations));
}
return ok({ ...order, status: 'validated' as const });
}
/**
* Check inventory for all items
*/
async function checkInventory(order: Order): Promise<Result<Order, OrderError>> {
const checkItem = async (
item: OrderItem
): Promise<Result<void, OrderError>> => {
const response = await fetch(`/api/inventory/${item.sku}`);
const inventory = await response.json();
if (inventory.available < item.quantity) {
return err(
inventoryError(item.sku, item.quantity, inventory.available)
);
}
return ok(undefined);
};
// Check all items - fail on first error
const result = await traverse(order.items, checkItem);
return result.map(() => order);
}
/**
* Process payment
*/
async function chargeCustomer(order: Order): Promise<Result<Order, OrderError>> {
const total = order.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({
customerId: order.customerId,
amount: total,
orderId: order.id,
}),
});
if (!response.ok) {
const error = await response.json();
return err(paymentError(error.code, error.transactionId));
}
return ok({ ...order, status: 'paid' as const });
}
/**
* Ship order
*/
async function shipOrder(order: Order): Promise<Result<Order, OrderError>> {
const response = await fetch('/api/shipping', {
method: 'POST',
body: JSON.stringify({
orderId: order.id,
address: order.shippingAddress,
items: order.items,
}),
});
if (!response.ok) {
const error = await response.json();
return err(shippingError(error.carrier, error.reason));
}
return ok({ ...order, status: 'shipped' as const });
}
/**
* The complete pipeline - explicit about all possible failures
*/
export async function processOrder(
orderId: string
): Promise<Result<Order, OrderError>> {
// Using flatMap to chain async operations
const orderResult = await fetchOrder(orderId);
if (orderResult.isErr()) return orderResult;
const validatedResult = validateOrder(orderResult.value);
if (validatedResult.isErr()) return validatedResult;
const inventoryResult = await checkInventory(validatedResult.value);
if (inventoryResult.isErr()) return inventoryResult;
const paymentResult = await chargeCustomer(inventoryResult.value);
if (paymentResult.isErr()) return paymentResult;
return shipOrder(paymentResult.value);
}
// Alternative: Using Do-notation style for cleaner syntax
export async function processOrderDo(
orderId: string
): Promise<Result<Order, OrderError>> {
return Do(function* () {
const order = yield* fetchOrder(orderId);
const validated = yield* ok(validateOrder(order)).flatMap(r => r);
const withInventory = yield* checkInventory(validated);
const paid = yield* chargeCustomer(withInventory);
const shipped = yield* shipOrder(paid);
return shipped;
});
}
The type signature now tells us exactly what can go wrong:
processOrder(orderId: string): Promise<Result<Order, OrderError>>
And OrderError is a union type—the caller must handle each case:
// Exhaustive pattern matching
const result = await processOrder('ord-123');
result.match({
ok: (order) => {
console.log(`Order ${order.id} shipped!`);
},
err: (error) => {
switch (error._tag) {
case 'OrderNotFoundError':
return notFound(error.orderId);
case 'OrderValidationError':
return badRequest(error.violations);
case 'InventoryError':
return conflict(`${error.sku} out of stock`);
case 'PaymentError':
return paymentRequired(error.code);
case 'ShippingError':
return serviceUnavailable(error.carrier);
default:
// TypeScript ensures this is unreachable
const _exhaustive: never = error;
throw new Error('Unhandled error type');
}
},
});
Async Pipeline Builders
For complex async flows, build a composable pipeline:
// async-pipeline.ts
import { Result, ok, err } from './result';
type AsyncResult<T, E> = Promise<Result<T, E>>;
/**
* AsyncResult pipeline builder
*/
export class Pipeline<T, E> {
constructor(private readonly result: AsyncResult<T, E>) {}
static of<T, E>(value: T): Pipeline<T, E> {
return new Pipeline(Promise.resolve(ok(value)));
}
static from<T, E>(result: AsyncResult<T, E>): Pipeline<T, E> {
return new Pipeline(result);
}
static fromSync<T, E>(result: Result<T, E>): Pipeline<T, E> {
return new Pipeline(Promise.resolve(result));
}
/**
* Transform success value synchronously
*/
map<U>(fn: (value: T) => U): Pipeline<U, E> {
return new Pipeline(
this.result.then((r) => r.map(fn))
);
}
/**
* Transform success value asynchronously
*/
mapAsync<U>(fn: (value: T) => Promise<U>): Pipeline<U, E> {
return new Pipeline(
this.result.then(async (r) => {
if (r.isErr()) return err(r.error);
return ok(await fn(r.value));
})
);
}
/**
* Chain with another Result-returning function
*/
flatMap<U>(fn: (value: T) => Result<U, E>): Pipeline<U, E> {
return new Pipeline(
this.result.then((r) => r.flatMap(fn))
);
}
/**
* Chain with async Result-returning function
*/
flatMapAsync<U>(fn: (value: T) => AsyncResult<U, E>): Pipeline<U, E> {
return new Pipeline(
this.result.then(async (r) => {
if (r.isErr()) return err(r.error);
return fn(r.value);
})
);
}
/**
* Transform error type
*/
mapErr<F>(fn: (error: E) => F): Pipeline<T, F> {
return new Pipeline(
this.result.then((r) => r.mapErr(fn))
);
}
/**
* Recover from specific errors
*/
recover(fn: (error: E) => Result<T, E>): Pipeline<T, E> {
return new Pipeline(
this.result.then((r) => r.orElse(fn))
);
}
/**
* Recover asynchronously
*/
recoverAsync(fn: (error: E) => AsyncResult<T, E>): Pipeline<T, E> {
return new Pipeline(
this.result.then(async (r) => {
if (r.isOk()) return r;
return fn(r.error);
})
);
}
/**
* Execute side effect on success
*/
tap(fn: (value: T) => void): Pipeline<T, E> {
return new Pipeline(
this.result.then((r) => {
if (r.isOk()) fn(r.value);
return r;
})
);
}
/**
* Execute async side effect on success
*/
tapAsync(fn: (value: T) => Promise<void>): Pipeline<T, E> {
return new Pipeline(
this.result.then(async (r) => {
if (r.isOk()) await fn(r.value);
return r;
})
);
}
/**
* Execute side effect on error
*/
tapErr(fn: (error: E) => void): Pipeline<T, E> {
return new Pipeline(
this.result.then((r) => {
if (r.isErr()) fn(r.error);
return r;
})
);
}
/**
* Ensure a condition, converting to error if false
*/
ensure(
predicate: (value: T) => boolean,
error: E | ((value: T) => E)
): Pipeline<T, E> {
return new Pipeline(
this.result.then((r) => {
if (r.isErr()) return r;
if (!predicate(r.value)) {
const e = typeof error === 'function'
? (error as (value: T) => E)(r.value)
: error;
return err(e);
}
return r;
})
);
}
/**
* Timeout the pipeline
*/
timeout(ms: number, error: E): Pipeline<T, E> {
return new Pipeline(
Promise.race([
this.result,
new Promise<Result<T, E>>((resolve) =>
setTimeout(() => resolve(err(error)), ms)
),
])
);
}
/**
* Retry on failure
*/
retry(attempts: number, delay: number = 0): Pipeline<T, E> {
return new Pipeline(
this.result.then(async (r) => {
if (r.isOk() || attempts <= 0) return r;
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
return this.retry(attempts - 1, delay).run();
})
);
}
/**
* Execute the pipeline
*/
run(): AsyncResult<T, E> {
return this.result;
}
}
// Usage example
const processOrderPipeline = (orderId: string) =>
Pipeline.from(fetchOrder(orderId))
.flatMap(validateOrder)
.flatMapAsync(checkInventory)
.tap((order) => console.log(`Inventory OK for ${order.id}`))
.flatMapAsync(chargeCustomer)
.tapErr((error) => metrics.increment('payment_failed'))
.flatMapAsync(shipOrder)
.tap((order) => analytics.track('order_shipped', { orderId: order.id }))
.timeout(30000, { _tag: 'TimeoutError', message: 'Order processing timed out' } as any)
.run();
Validation Accumulation
Sometimes you want to collect all errors, not just the first:
// validation.ts
import { Result, ok, err } from './result';
/**
* Validation collects errors instead of short-circuiting
*/
export type Validation<T, E> = Result<T, E[]>;
/**
* Combine validations, accumulating all errors
*/
export function validateAll<T extends readonly Validation<unknown, unknown>[]>(
validations: T
): Validation<
{ [K in keyof T]: T[K] extends Validation<infer U, unknown> ? U : never },
T[number] extends Validation<unknown, infer E> ? E : never
> {
const values: unknown[] = [];
const errors: unknown[] = [];
for (const validation of validations) {
validation.match({
ok: (value) => values.push(value),
err: (errs) => errors.push(...errs),
});
}
if (errors.length > 0) {
return err(errors) as any;
}
return ok(values) as any;
}
/**
* Create a single-error validation
*/
export function validate<T, E>(
result: Result<T, E>
): Validation<T, E> {
return result.mapErr((e) => [e]);
}
/**
* Validation builder for complex objects
*/
export class Validator<T, E> {
private validations: Array<(value: T) => Validation<unknown, E>> = [];
rule<K extends keyof T>(
field: K,
validate: (value: T[K]) => Result<T[K], E>
): this {
this.validations.push((value) =>
validate(value[field]).mapErr((e) => [e])
);
return this;
}
custom(validate: (value: T) => Result<unknown, E>): this {
this.validations.push((value) =>
validate(value).mapErr((e) => [e])
);
return this;
}
validate(value: T): Validation<T, E> {
const results = this.validations.map((v) => v(value));
const combined = validateAll(results);
return combined.map(() => value);
}
}
// Usage
interface UserInput {
email: string;
password: string;
age: number;
}
interface ValidationError {
field: string;
message: string;
}
const emailError = (msg: string): ValidationError => ({ field: 'email', message: msg });
const passwordError = (msg: string): ValidationError => ({ field: 'password', message: msg });
const ageError = (msg: string): ValidationError => ({ field: 'age', message: msg });
const validateEmail = (email: string): Result<string, ValidationError> => {
if (!email.includes('@')) {
return err(emailError('Invalid email format'));
}
return ok(email);
};
const validatePassword = (password: string): Result<string, ValidationError> => {
if (password.length < 8) {
return err(passwordError('Password must be at least 8 characters'));
}
if (!/[A-Z]/.test(password)) {
return err(passwordError('Password must contain uppercase letter'));
}
return ok(password);
};
const validateAge = (age: number): Result<number, ValidationError> => {
if (age < 18) {
return err(ageError('Must be at least 18 years old'));
}
if (age > 120) {
return err(ageError('Invalid age'));
}
return ok(age);
};
const userValidator = new Validator<UserInput, ValidationError>()
.rule('email', validateEmail)
.rule('password', validatePassword)
.rule('age', validateAge);
// Returns ALL validation errors at once
const result = userValidator.validate({
email: 'invalid',
password: 'weak',
age: 15,
});
// Result: Err([
// { field: 'email', message: 'Invalid email format' },
// { field: 'password', message: 'Password must be at least 8 characters' },
// { field: 'password', message: 'Password must contain uppercase letter' },
// { field: 'age', message: 'Must be at least 18 years old' }
// ])
Integration with React
// use-result.ts
import { useState, useCallback } from 'react';
import { Result, ok, err } from './result';
interface UseResultState<T, E> {
result: Result<T, E> | null;
isLoading: boolean;
}
export function useResult<T, E, Args extends unknown[]>(
fn: (...args: Args) => Promise<Result<T, E>>
) {
const [state, setState] = useState<UseResultState<T, E>>({
result: null,
isLoading: false,
});
const execute = useCallback(
async (...args: Args) => {
setState({ result: null, isLoading: true });
const result = await fn(...args);
setState({ result, isLoading: false });
return result;
},
[fn]
);
return {
...state,
execute,
isOk: state.result?.isOk() ?? false,
isErr: state.result?.isErr() ?? false,
value: state.result?.isOk() ? state.result.value : undefined,
error: state.result?.isErr() ? state.result.error : undefined,
};
}
// Component usage
function OrderForm({ orderId }: { orderId: string }) {
const { execute, isLoading, result, error } = useResult(processOrder);
const handleSubmit = async () => {
const result = await execute(orderId);
result.match({
ok: (order) => {
toast.success(`Order ${order.id} processed!`);
router.push('/orders');
},
err: (error) => {
// Type-safe error handling
switch (error._tag) {
case 'PaymentError':
toast.error(`Payment failed: ${error.code}`);
break;
case 'InventoryError':
toast.error(`${error.sku} is out of stock`);
break;
default:
toast.error(error.message);
}
},
});
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Processing...' : 'Submit Order'}
</button>
{error && (
<ErrorDisplay error={error} />
)}
</form>
);
}
// Type-safe error display
function ErrorDisplay({ error }: { error: OrderError }) {
switch (error._tag) {
case 'OrderValidationError':
return (
<ul className="error-list">
{error.violations.map((v, i) => (
<li key={i}>
<strong>{v.field}:</strong> {v.message}
</li>
))}
</ul>
);
case 'PaymentError':
return (
<div className="payment-error">
<p>Payment failed: {error.code}</p>
{error.transactionId && (
<p>Reference: {error.transactionId}</p>
)}
</div>
);
default:
return <p className="error">{error.message}</p>;
}
}
Error Recovery Patterns
// recovery.ts
import { Result, ok, err } from './result';
import { Pipeline } from './async-pipeline';
/**
* Retry with exponential backoff
*/
export async function retryWithBackoff<T, E>(
fn: () => Promise<Result<T, E>>,
options: {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
shouldRetry?: (error: E) => boolean;
}
): Promise<Result<T, E>> {
const { maxAttempts, baseDelay, maxDelay, shouldRetry = () => true } = options;
let lastError: E | undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await fn();
if (result.isOk()) {
return result;
}
lastError = result.error;
if (!shouldRetry(result.error) || attempt === maxAttempts - 1) {
return result;
}
const delay = Math.min(
baseDelay * Math.pow(2, attempt),
maxDelay
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
return err(lastError!);
}
/**
* Circuit breaker pattern
*/
export class CircuitBreaker<T, E> {
private failures = 0;
private lastFailure: number | null = null;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private readonly fn: () => Promise<Result<T, E>>,
private readonly options: {
failureThreshold: number;
resetTimeout: number;
onStateChange?: (state: 'closed' | 'open' | 'half-open') => void;
}
) {}
private setState(state: 'closed' | 'open' | 'half-open') {
if (this.state !== state) {
this.state = state;
this.options.onStateChange?.(state);
}
}
async execute(): Promise<Result<T, E | CircuitBreakerError>> {
if (this.state === 'open') {
const timeSinceFailure = Date.now() - (this.lastFailure ?? 0);
if (timeSinceFailure > this.options.resetTimeout) {
this.setState('half-open');
} else {
return err({
_tag: 'CircuitBreakerError',
message: 'Circuit breaker is open',
state: this.state,
} as CircuitBreakerError);
}
}
const result = await this.fn();
if (result.isOk()) {
this.failures = 0;
this.setState('closed');
return result;
}
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.options.failureThreshold) {
this.setState('open');
}
return result;
}
}
interface CircuitBreakerError {
_tag: 'CircuitBreakerError';
message: string;
state: 'open' | 'half-open';
}
/**
* Fallback chain - try multiple strategies
*/
export async function withFallback<T, E>(
strategies: Array<() => Promise<Result<T, E>>>
): Promise<Result<T, E[]>> {
const errors: E[] = [];
for (const strategy of strategies) {
const result = await strategy();
if (result.isOk()) {
return ok(result.value);
}
errors.push(result.error);
}
return err(errors);
}
// Usage
const fetchUserData = async (userId: string) => {
return withFallback([
// Primary: API
() => fetchFromApi(userId),
// Fallback 1: Cache
() => fetchFromCache(userId),
// Fallback 2: Local storage
() => fetchFromLocalStorage(userId),
]);
};
/**
* Graceful degradation
*/
export async function withDegradation<T, E>(
primary: () => Promise<Result<T, E>>,
degraded: () => Promise<Result<T, E>>,
shouldDegrade: (error: E) => boolean
): Promise<Result<T, E>> {
const result = await primary();
if (result.isOk()) {
return result;
}
if (shouldDegrade(result.error)) {
return degraded();
}
return result;
}
Performance Considerations
// Avoiding allocation overhead for hot paths
/**
* Singleton Ok/Err for primitive results
*/
const OK_VOID: Result<void, never> = ok(undefined);
const OK_TRUE: Result<boolean, never> = ok(true);
const OK_FALSE: Result<boolean, never> = ok(false);
export function okVoid(): Result<void, never> {
return OK_VOID;
}
export function okBool(value: boolean): Result<boolean, never> {
return value ? OK_TRUE : OK_FALSE;
}
/**
* Tagged union alternative (no class overhead)
*/
type ResultUnion<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Faster for hot paths but less ergonomic
const okU = <T>(value: T): ResultUnion<T, never> => ({ ok: true, value });
const errU = <E>(error: E): ResultUnion<never, E> => ({ ok: false, error });
/**
* Benchmark comparison
*/
const iterations = 1_000_000;
// Class-based Result
console.time('class-based');
for (let i = 0; i < iterations; i++) {
const r = ok(i).map(x => x * 2).flatMap(x => ok(x + 1));
}
console.timeEnd('class-based');
// Tagged union
console.time('tagged-union');
for (let i = 0; i < iterations; i++) {
const r1 = okU(i);
const r2 = r1.ok ? okU(r1.value * 2) : r1;
const r3 = r2.ok ? okU(r2.value + 1) : r2;
}
console.timeEnd('tagged-union');
// Results typically show tagged unions are 2-3x faster
// but class-based is more ergonomic for most applications
Library Ecosystem
For production use, consider these battle-tested libraries:
// neverthrow - Most popular Result implementation
import { ok, err, Result, ResultAsync } from 'neverthrow';
const divide = (a: number, b: number): Result<number, string> =>
b === 0 ? err('Division by zero') : ok(a / b);
// fp-ts - Full functional programming toolkit
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
const divideE = (a: number, b: number): E.Either<string, number> =>
b === 0 ? E.left('Division by zero') : E.right(a / b);
const result = pipe(
divideE(10, 2),
E.map(x => x * 2),
E.flatMap(x => divideE(x, 0))
);
// effect-ts - Modern Effect system
import { Effect, pipe } from 'effect';
const divideEffect = (a: number, b: number) =>
b === 0
? Effect.fail(new Error('Division by zero'))
: Effect.succeed(a / b);
const program = pipe(
divideEffect(10, 2),
Effect.flatMap(x => divideEffect(x, 0)),
Effect.catchAll(e => Effect.succeed(0))
);
// oxide.ts - Rust-inspired Result/Option
import { Result, Ok, Err, Option, Some, None } from 'oxide.ts';
const divide = (a: number, b: number): Result<number, string> =>
b === 0 ? Err('Division by zero') : Ok(a / b);
Migration Strategy
Adopting Result incrementally without rewriting everything:
// migration.ts
import { Result, ok, err, tryCatchAsync } from './result';
/**
* Wrap existing throwing functions
*/
export function wrapThrowing<T, Args extends unknown[]>(
fn: (...args: Args) => T,
errorMapper: (e: unknown) => Error = (e) => e as Error
): (...args: Args) => Result<T, Error> {
return (...args) => {
try {
return ok(fn(...args));
} catch (e) {
return err(errorMapper(e));
}
};
}
/**
* Wrap existing async throwing functions
*/
export function wrapThrowingAsync<T, Args extends unknown[]>(
fn: (...args: Args) => Promise<T>,
errorMapper: (e: unknown) => Error = (e) => e as Error
): (...args: Args) => Promise<Result<T, Error>> {
return async (...args) => {
try {
return ok(await fn(...args));
} catch (e) {
return err(errorMapper(e));
}
};
}
// Wrap existing code
const legacyFetch = async (url: string) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
};
const safeFetch = wrapThrowingAsync(legacyFetch);
// Gradual adoption
class UserService {
// Old method (keep for backwards compatibility)
async getUser(id: string): Promise<User> {
const result = await this.getUserSafe(id);
if (result.isErr()) throw result.error;
return result.value;
}
// New method (preferred)
async getUserSafe(id: string): Promise<Result<User, UserError>> {
return tryCatchAsync(
() => this.repository.findById(id),
(e) => userError(e)
);
}
}
/**
* Boundary conversion - at API edges
*/
export function resultToResponse<T>(
result: Result<T, AppError>
): Response {
return result.match({
ok: (data) => Response.json({ data }, { status: 200 }),
err: (error) => Response.json(
{ error: { code: error._tag, message: error.message } },
{ status: errorToStatus(error) }
),
});
}
function errorToStatus(error: AppError): number {
switch (error._tag) {
case 'NotFoundError': return 404;
case 'ValidationError': return 400;
case 'AuthorizationError': return 403;
case 'ConflictError': return 409;
default: return 500;
}
}
Checklist for Adoption
Before starting:
- Team agrees on error handling philosophy
- Chosen library (neverthrow, fp-ts, custom) approved
- ESLint rules configured to prevent
throwin new code - Training completed on monadic patterns
Implementation:
- Error type hierarchy designed for domain
- Utility functions (
tryCatch,all,traverse) in shared lib - React hooks for Result state management
- API boundary adapters (Result → HTTP response)
Migration:
- Wrapper functions for legacy throwing code
- New code uses Result exclusively
- Gradual conversion of critical paths
- Documentation for both patterns during transition
Testing:
- Test both success and error paths explicitly
- Property-based tests for combinators
- Integration tests verify error propagation
Monitoring:
- Structured logging includes error discriminants
- Metrics track error types (not just "error happened")
- Alerts based on specific error patterns
Summary
Railway Oriented Programming with Result and Option types transforms error handling from:
- Invisible → Explicit — Functions declare what can fail
- Optional → Mandatory — Compiler enforces error handling
- Untyped → Typed — Exhaustive pattern matching on error variants
- Nested → Linear — Chain operations without callback hell
The tradeoff is verbosity and learning curve. But for any system where reliability matters—payments, orders, authentication—the compile-time guarantees are worth it.
The key insight: Exceptions are for exceptional circumstances. Business rule violations are not exceptional—they're expected. Model them accordingly.
What did you think?