Error handling

Never throw: Result types in production TypeScript

TypeFirst · AgileLabs Engineering · 8 min read

What does this function's type tell you about how it can fail?

function parseAge(input: string): number

Nothing. Maybe it returns NaN. Maybe it throws. Maybe it throws only for the empty string, or only on Tuesdays. The signature — the one thing the compiler checks — is silent about the single most important fact about this function. throw is invisible in TypeScript's types. Every try/catch you write is you guessing, from documentation or bitter experience, where the landmines are.

The fix is old, boring, and works: make failure a value.

Result is twenty lines, not a framework

Here is the entire core of the Result type from ts-bedrock:

// Core/Data/Result.ts
export type Result<E, T> = ResultErr<E> | ResultOk<T>

export type ResultErr<E> = {
  readonly _t: "Err"
  readonly error: E
}
export type ResultOk<T> = {
  readonly _t: "Ok"
  readonly value: T
}

export function ok<T>(value: T): ResultOk<T> {
  return { _t: "Ok", value }
}
export function err<E>(error: E): ResultErr<E> {
  return { _t: "Err", error }
}

No monad tutorial required. It's a tagged union — the same sum type pattern that makes impossible states unrepresentable, applied to failure. The payoff is in the signature:

function parseAge(input: string): Result<"NOT_A_NUMBER" | "NEGATIVE", number>

Now the failure modes are documentation the compiler enforces. A caller cannot touch the number without going through the union — and when they switch on the error, the match is exhaustive. Add a third failure mode next month and every call site fails to compile until it's handled.

Errors with names, not messages

Notice the error type is "NOT_A_NUMBER" | "NEGATIVE" — a closed union of codes, not Error and not string. Messages are for humans and belong at the edge, where UI code translates codes to copy. Codes are for logic. This is exactly how ts-bedrock's API handlers work — the error union comes straight from the endpoint's shared contract:

export async function handler(
  params: API.BodyParams,
): Promise<Result<API.ErrorCode, API.Payload>> {
  const userRow = await UserRow.getByEmail(params.email)
  if (userRow == null) return err("USER_NOT_FOUND")

  const isValid = await Hash.verify(…)
  if (isValid === false) return err("INVALID_PASSWORD")

  return ok(await loginPayload(userRow))
}

Control flow reads top to bottom. No exception can teleport out of this function past three layers of callers into a generic 500 handler. The failure goes exactly where the return value goes.

When you don't care why: Maybe

Sometimes there's only one way to fail, or the caller doesn't care which. For that, Result has a cheaper sibling:

export type Maybe<T> = T | null

That's it — TypeFirst deliberately uses T | null instead of a wrapped Option object, because TypeScript's narrowing already handles it: one if (x == null) and the rest of the function sees T. The convention pair is simple: Result when the caller should handle the error, Maybe when the caller only needs to know it's absent. ts-bedrock ships both flavors of every smart constructor — createEmailE returns Result<ErrorEmail, Email>, createEmail returns Maybe<Email>.

So is throw banned completely?

Almost. The rule in production has exactly two exceptions:

Everything else — parsing, validation, not-found lookups, permission checks, anything a user can trigger — returns a value. After a while the mental shift sticks: an "error" is just another state of your domain, and states belong in types. try/catch stops being control flow and goes back to being what it should have been all along: a fire alarm.

See it end to end. Core/Data/Result.ts is the whole implementation; every API handler in ts-bedrock returns one. Then read how the same unions travel the wire in End-to-end type-safe APIs.

← All posts