Skip to content

Error Handling in TypeScript: Patterns That Scale

Move beyond try-catch soup: discriminated unions, Result types, and error boundaries that make TypeScript codebases more reliable and maintainable.

3 min read
TypeScript code showing structured error handling with Result types and discriminated unions

Most TypeScript codebases handle errors the same way JavaScript always has — try/catch blocks everywhere, unknown caught errors cast to any, and hope that nothing important slips through. The type system that was supposed to protect you is completely blind to failure paths. There's a better way, and it doesn't require a library.

Why Try-Catch Doesn't Scale

The fundamental problem with try/catch as your primary error handling strategy is that errors become invisible at the type level. A function's signature promises it returns User, but it might throw five different exceptions. The caller has no way to know without reading the implementation.

tstypescript
// ❌ The signature lies — this can fail in at least three ways
async function getUser(id: string): Promise<User> {
  const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  if (!row) throw new Error("User not found");
  return parseUser(row); // can also throw if schema changed
}
 
// ✅ The type signature tells the whole story
async function getUser(
  id: string,
): Promise<Result<User, "NOT_FOUND" | "PARSE_ERROR" | "DB_ERROR">> {
  try {
    const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
    if (!row) return err("NOT_FOUND");
    const parsed = parseUser(row);
    if (!parsed.ok) return err("PARSE_ERROR");
    return ok(parsed.value);
  } catch {
    return err("DB_ERROR");
  }
}

The second version makes a contract: callers must handle failure. The compiler enforces it.

Building a Lightweight Result Type

You don't need fp-ts or neverthrow for most applications. A minimal implementation covers 90% of real use cases.

tstypescript
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
 
function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
 
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}
 
// Type guard for direct discrimination
function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
  return result.ok;
}

The discriminated union means TypeScript narrows automatically when you check result.ok. No casting, no as assertions — the compiler tracks which branch you're in.

When to Throw vs. When to Return

Not every failure should be a Result. Mixing the two incorrectly creates noise. Use this heuristic:

ScenarioApproachReasoning
Expected failure (not found, validation error)Result<T, E>Caller is expected to handle it
Programmer bug (null dereference, wrong assumption)throwShould crash loudly — it's a bug
Infrastructure failure (network timeout, disk full)throw at boundaryRetry logic belongs at the edge
User-facing form validationResult<T, ValidationError[]>Multiple errors, structured feedback

The goal is that throw means "this should never happen in correct code." When it does happen, you want it loud and traceable.

Composing Results

The real power comes when you chain operations that can each fail. Without a helper, this turns into nested pattern matching.

tstypescript
// Utility for chaining fallible operations
function andThen<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>,
): Result<U, E> {
  if (!result.ok) return result;
  return fn(result.value);
}
 
// Usage: each step can fail, errors propagate automatically
async function processOrder(
  orderId: string,
): Promise<Result<Receipt, OrderError>> {
  const order = await fetchOrder(orderId);
  if (!order.ok) return order;
 
  const inventory = await checkInventory(order.value.items);
  if (!inventory.ok) return inventory;
 
  const payment = await chargePayment(order.value.total);
  if (!payment.ok) return payment;
 
  return ok(generateReceipt(order.value, payment.value));
}

Each step has a typed error. The caller of processOrder sees a union of all possible errors and handles each explicitly.

Error Boundaries at the Edge

Keep try/catch at system boundaries. HTTP handlers, queue consumers, and CLI entry points are the right place. Everything inside is typed Results that propagate cleanly.

tstypescript
// API route handler — the only place with try/catch
export async function POST(req: Request) {
  try {
    const body = await req.json();
    const result = await processOrder(body.orderId);
 
    if (!result.ok) {
      const statusMap: Record<OrderError, number> = {
        NOT_FOUND: 404,
        INVENTORY_UNAVAILABLE: 409,
        PAYMENT_FAILED: 402,
        DB_ERROR: 500,
      };
      return Response.json(
        { error: result.error },
        { status: statusMap[result.error] ?? 500 },
      );
    }
 
    return Response.json(result.value, { status: 201 });
  } catch (e) {
    // Truly unexpected — log and fail
    console.error("Unhandled error in POST /orders", e);
    return Response.json({ error: "INTERNAL_ERROR" }, { status: 500 });
  }
}

Infrastructure errors (try/catch) stay at the boundary. Business logic errors (Result) are handled explicitly. The distinction makes the code easier to reason about and test.

Testing Error Paths

One underappreciated benefit of Result types: they make error paths trivially testable. No need to mock throw behavior — just return the appropriate error type.

tstypescript
// Testing is clean — no try/catch, no mock implementations that throw
describe("processOrder", () => {
  it("returns INVENTORY_UNAVAILABLE when stock is depleted", async () => {
    mockFetchOrder.mockResolvedValue(ok(sampleOrder));
    mockCheckInventory.mockResolvedValue(err("INVENTORY_UNAVAILABLE" as const));
 
    const result = await processOrder("order-123");
 
    expect(result.ok).toBe(false);
    expect(result.ok === false && result.error).toBe("INVENTORY_UNAVAILABLE");
  });
});

Compare this to testing code that throws — you need expect(...).rejects.toThrow(...), which loses type information and requires extra ceremony.

Key Takeaways

  1. try/catch hides errors from the type system — use Result<T, E> for expected failures so the compiler tracks them
  2. Discriminated unions give you zero-cost typed errors — no library required, no runtime overhead
  3. Reserve throw for genuine programmer bugs — if it's a recoverable condition, it should be a Result
  4. Keep error boundaries at system edges — HTTP handlers and queue consumers are the right place for infrastructure try/catch
  5. Typed errors make testing easier — you can return error Results directly without mocking throw behavior
  6. Compose Results with helpers — chaining fallible operations stays readable with a simple andThen utility
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX