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:

  1. The contract is implicit — the signature lies, it promises a Config but can explode.
  2. Errors mix — the catch block catches everything, including unexpected bugs.
  3. 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 match API
  • Effect — for going further with a complete effect system
  • oxide.ts — Rust-like API with Option and Result

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.