Handling Error in TypeScript

May 28, 2026 · 2 min read

TypeScript is meticulous about the shape of your data and completely silent about the ways it can fail. A function typed as (id: string) => User looks total, but the moment it can throw, that signature is lying. The type system never sees the exception, so neither does the person calling the function.

The problem with throwing

throw is an invisible control-flow channel. Nothing in a function's type tells you whether it can fail, what it fails with, or which call sites need a try/catch. And because catch binds to unknown (or worse, any), you lose every guarantee the moment you cross the boundary.

async function loadUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error("Request failed") // invisible to the type
  return res.json()
}

The caller has no idea this can fail. The compiler is happy. Production is not.

Model failure as a value

The fix is to stop treating errors as a side channel and start returning them. A Result type — a tagged union of success and failure — puts the failure into the return type, where the compiler can enforce that you deal with it.

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E }

Now the signature tells the whole truth:

async function loadUser(
  id: string,
): Promise<Result<User, "not-found" | "network">> {
  const res = await fetch(`/api/users/${id}`)
  if (res.status === 404) return { ok: false, error: "not-found" }
  if (!res.ok) return { ok: false, error: "network" }
  return { ok: true, value: await res.json() }
}

Let exhaustiveness do the work

Because error is a finite union, narrowing forces you to handle every branch. Add a never assertion in the default and a new error variant becomes a compile error at every call site — the type system turns "did I handle this?" into a build step.

const result = await loadUser(id)
 
if (result.ok) {
  render(result.value)
} else {
  switch (result.error) {
    case "not-found":
      return showEmptyState()
    case "network":
      return showRetry()
    default:
      return assertNever(result.error)
  }
}
 
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${value}`)
}

When throwing is still fine

This isn't a ban on exceptions. throw is the right tool for bugs — violated invariants, impossible states, programmer mistakes you want to crash loudly. Reserve it for the things no caller should ever recover from, and return Result for the failures that are part of normal operation.

Expected failures are data. Unexpected failures are exceptions. The trouble starts when you use exceptions for the first kind — and the type system stops being able to help you.

The rule of thumb: if a reasonable caller might want to react to the failure, it belongs in the return type, not in a catch.