Error handling
Never throw: Result types in production TypeScript
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:
- Unrecoverable failures. The database is unreachable, the JWT secret is missing at boot. Nobody up the stack can handle that — let it crash loudly, restart, page someone. Wrapping it in a
Resultwould force every caller to pretend there's a recovery path that doesn't exist. - Inside decoder
.verify(). At the decode boundary, the library catches and converts throws into typed decode failures — the throw never escapes.
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.