Either/Result: Handling Errors Without Exceptions
Replace unpredictable try/catch blocks with the Either/Result pattern for an explicit, typed, and composable error flow.
Exceptions are convenient, but they create an invisible control flow. When a function can fail, nothing in its signature indicates it. The Either/Result pattern makes errors explicit, typed, and impossible to ignore.
The Problem with Exceptions
// ❌ Nothing in the signature says it can fail
function parseConfig(raw: string): Config {
const json = JSON.parse(raw); // 💥 can throw
if (!json.port) throw new Error("Missing port");
if (!json.host) throw new Error("Missing host");
return { port: json.port, host: json.host };
}
// The caller has to "guess" that a try/catch is needed
try {
const config = parseConfig(rawInput);
startServer(config);
} catch (e) {
// What error? Invalid JSON? Missing port? Something else?
console.error("Something went wrong", e);
}
Three major problems:
- The contract is implicit — the signature lies, it promises a
Configbut can explode. - Errors mix — the
catchblock catches everything, including unexpected bugs. - Composition is fragile — chaining fallible operations becomes a stack of try/catch blocks.
The Result Type
The idea is simple: a function that might fail returns either a success, or an error. Always.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Helpers to create results
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function Err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
That’s it. No library, no magic. Let’s rewrite our function:
// ✅ The signature explicitly states what can happen
type ConfigError =
| { type: "INVALID_JSON"; message: string }
| { type: "MISSING_FIELD"; field: string };
function parseConfig(raw: string): Result<Config, ConfigError> {
let json: unknown;
try {
json = JSON.parse(raw);
} catch {
return Err({ type: "INVALID_JSON", message: "Input is not valid JSON" });
}
// Assume isObject is defined elsewhere to check if json is an object
// For simplicity, directly access properties for demonstration
if (typeof json !== 'object' || json === null || !('port' in json)) {
return Err({ type: "MISSING_FIELD", field: "port" });
}
if (!('host' in json)) {
return Err({ type: "MISSING_FIELD", field: "host" });
}
return Ok({ port: (json as any).port, host: (json as any).host });
}
// Helper to check if value is an object (for demonstration, a simple check)
function isObject(value: unknown): value is Record<string, any> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
The caller is now forced to handle both cases:
// Assuming rawInput is defined
// type Config = { port: number; host: string; };
// declare const rawInput: string;
const result = parseConfig(rawInput);
if (!result.ok) {
// TypeScript knows the exact error type
switch (result.error.type) {
case "INVALID_JSON":
console.error("Corrupted file:", result.error.message);
break;
case "MISSING_FIELD":
console.error(`Missing field: ${result.error.field}`);
break;
}
// process.exit(1); // In a real app, you might exit or handle gracefully
} else {
// Here, TypeScript knows result.value is a Config
// startServer(result.value); // Assuming startServer exists
console.log("Config parsed successfully:", result.value);
}
Composing Results
The real power emerges when chaining multiple fallible operations. Without Result, it’s nested try/catch. With Result, it’s linear.
function map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
return result.ok ? Ok(fn(result.value)) : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
// Helper for functional piping (simplified for example)
const pipe = <A extends any[], B>(
f1: (...args: A) => B,
...fns: ((a: B) => B)[]
) => (...args: A) => fns.reduce((acc, fn) => fn(acc), f1(...args));
// Dummy functions for demonstration
function readFile(path: string): Result<string, AppError> {
if (path === "/error/path.json") return Err({ type: "FILE_NOT_FOUND", message: "File not found" });
return Ok("{ \"port\": 8080, \"host\": \"localhost\" }");
}
type RawConfig = { port: number; host: string }; // Simplified
function parseConfig(raw: string): Result<RawConfig, AppError> {
try {
const json = JSON.parse(raw);
if (!isObject(json) || !('port' in json) || !('host' in json)) {
return Err({ type: "INVALID_FORMAT", message: "Invalid config structure" });
}
return Ok(json as RawConfig);
} catch (e) {
return Err({ type: "INVALID_FORMAT", message: "Invalid JSON" });
}
}
function validateConfig(rawConfig: RawConfig): Result<Config, AppError> {
if (rawConfig.port < 1024) return Err({ type: "INVALID_PORT", message: "Port too low" });
return Ok(rawConfig as Config);
}
type AppError = { type: string; message?: string };
// Example concrete — load, parse, and validate a config:
function loadConfig(path: string): Result<Config, AppError> {
return pipe(
() => readFile(path), // Result<string, AppError>
r => flatMap(r, parseConfig), // Result<RawConfig, AppError>
r => flatMap(r, validateConfig), // Result<Config, AppError>
)(); // Call the pipe function
}
const configResult = loadConfig("/etc/app.json");
// A single place to handle the error, regardless of which step failed
if (!configResult.ok) {
console.error("Failed to load config:", configResult.error);
} else {
console.log("Config loaded:", configResult.value);
}
Each step can fail, but the flow remains flat and readable. The first error short-circuits the rest, exactly like exceptions, but explicitly.
collectErrors Pattern for Validation
Sometimes, you want all errors, not just the first one. The Result pattern adapts:
type ValidationError = { field: string; message: string; };
type User = { name: string; email: string; age: number; };
function validateUser(input: Record<string, any>): Result<User, ValidationError[]> {
const errors: ValidationError[] = [];
if (!input.name || input.name.length < 2) {
errors.push({ field: "name", message: "Minimum 2 characters" });
}
if (!input.email || !(input.email as string).includes("@")) {
errors.push({ field: "email", message: "Invalid email" });
}
if (!input.age || input.age < 0 || input.age > 150) {
errors.push({ field: "age", message: "Invalid age" });
}
return errors.length > 0
? Err(errors)
: Ok({ name: input.name, email: input.email, age: input.age });
}
// Example usage:
// const validationInput = { name: "A", email: "invalid", age: -5 };
// const userValidationResult = validateUser(validationInput);
// if (!userValidationResult.ok) {
// console.error("Validation errors:", userValidationResult.error);
// } else {
// console.log("User is valid:", userValidationResult.value);
// }
Result in the Real World
HTTP Calls
type ApiError = { type: string; status?: number; };
// type User = { id: string; name: string; }; // Assuming User type is defined
async function fetchUser(id: string): Promise<Result<User, ApiError>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return Err({ type: "HTTP_ERROR", status: response.status });
}
const data = await response.json();
return Ok(data as User);
} catch {
return Err({ type: "NETWORK_ERROR" });
}
}
Service Layer
type CreateOrderDto = { userId: string; items: any[]; total: number; }; // Simplified DTO
type Order = { id: string; userId: string; items: any[]; total: number; }; // Simplified Order
type OrderError = { type: string; };
// Dummy functions for demonstration
async function checkStock(items: any[]): Promise<Result<boolean, OrderError>> {
// Simulate stock check
if (items.some(item => item.quantity > 10)) return Err({ type: "OUT_OF_STOCK" });
return Ok(true);
}
async function processPayment(user: User, total: number): Promise<Result<string, OrderError>> {
// Simulate payment processing
if (total > 1000) return Err({ type: "PAYMENT_FAILED" });
return Ok("payment_success_id_123");
}
function buildOrder(user: User, items: any[], paymentId: string): Order {
return { id: "order_id_456", userId: user.id, items, total: items.reduce((sum, item) => sum + item.price * item.quantity, 0) };
}
async function createOrder(dto: CreateOrderDto): Promise<Result<Order, OrderError>> {
const userResult = await fetchUser(dto.userId);
if (!userResult.ok) return Err({ type: "USER_NOT_FOUND" });
const stockResult = await checkStock(dto.items);
if (!stockResult.ok) return stockResult;
const paymentResult = await processPayment(userResult.value, dto.total);
if (!paymentResult.ok) return paymentResult;
return Ok(buildOrder(userResult.value, dto.items, paymentResult.value));
}
Each error is typed. The caller knows exactly what can go wrong.
When to Use Result (and When Not To)
Use Result for:
- Expected and recoverable errors (validation, parsing, network calls)
- System boundaries (APIs, files, databases)
- Functions whose callers need to react differently depending on the error
Keep exceptions for:
- Bugs and impossible states (
assert, violated invariants) - Errors that no caller can reasonably handle
- Deep infrastructure code (managed by the framework)
Existing Libraries
No need to reinvent the wheel. Several libraries offer a comprehensive Result type:
- neverthrow — lightweight, fluent API, excellent for TypeScript
- ts-results — Rust-inspired, includes
matchAPI - Effect — for going further with a complete effect system
- oxide.ts — Rust-like API with
OptionandResult
Key Takeaway
The Result pattern transforms errors from an invisible side effect into a first-class value. Code becomes more predictable, more typed, and easier to test. Start with your parsing and validation functions — that’s where the gain is immediate.