Return, Don't Throw
Return expected failures as one shared Result union built by success() and error(), so callers must look before touching data and throwing only ever means a bug.
Every API surface eventually grows two dialects for the same sentence. The route layer spells a refusal one way:
return Response.json({ error: 'unauthenticated' }, { status: 401 })and the helpers underneath spell it another:
return { ok: false, status: 409, error: 'out_of_stock' }Same fact ("this didn't work, here's why, here's the status"), hand-written at every site, in whatever shape that site happened to reach for. We had both dialects, nineteen routes deep, before standardizing on one.
What it replaces: the anti-pattern
The naive version scatters the failure contract across every function that can fail:
// DON'T: every site invents the sentence again
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) {
return Response.json({ error: 'unauthenticated' }, { status: 401 })
}
const { items } = await request.json()
const result = await placeOrder(user, items)
if (!result.ok) {
return Response.json({ error: result.error }, { status: result.status })
}
return Response.json({ order: result.order, remaining: result.remaining })
}Call this the "hand-rolled wire". Three problems compound:
- The failure shape is a convention, not a type. Nothing stops one helper from returning
{ message }instead of{ error }, or forgetting the status. Consistency is enforced by diligence, which is to say it drifts. - Every helper mints its own union.
PlaceOrderResult,ApplyCouponResult,RefundResult: each declared itsok: falsebranch by hand, each slightly different, and each route re-translated them to HTTP one field at a time. - Expected failure and genuine breakage share a channel. If helpers throw for "out of stock", then
catchblocks must sort user mistakes from real bugs, and sooner or later a bug gets caught, translated to a polite 400, and silenced.
The shape
One discriminated union, two constructors, and nobody writes the literals again:
export type Success<T> = { ok: true; data: T }
export type Failure = {
ok: false
error: string // snake_case wire code
status: number
}
export type Result<T> = Success<T> | Failure
export const success = <T>(data: T): Success<T> => ({ ok: true, data })
export const error = (code: string, status = 400): Failure => ({
ok: false,
error: code,
status,
})This is Phased State applied to a function's outcome. A call has exactly two phases (it worked or it didn't) and each branch carries only what that phase means: data doesn't exist on a failure, status doesn't exist on a success. The bag-of-fields alternative ({ data?, error?, status? }) permits data and error set at once, and every reader has to defend against it. The union closes the gap: you cannot touch data until you've looked at ok, and the compiler is the one checking.
Helpers with expected failure modes return it and never throw for them:
if (stock < item.qty) return error('out_of_stock', 409)
// ...
return success({ order, remaining })The caller narrows once and both branches fall out fully typed:
const result = await applyCoupon(user, code)
if (!result.ok) {
return Response.json({ error: result.error }, { status: result.status })
}
return Response.json(result.data)What it buys you
1. One vocabulary instead of N conventions
success()/error() are the only way a failure gets built, so every fallible helper speaks the same sentence. The per-helper unions (PlaceOrderResult, ApplyCouponResult, …) collapse into one Result<T> that only varies in T:
placeOrder(user, items) // Result<{ order: Order; remaining: number }>
applyCoupon(user, code) // Result<{ discount: number }>
refundOrder(orderId) // Result<{ refundId: string }>Routes stop re-translating bespoke shapes field by field. The !result.ok branch is identical everywhere, mechanical enough to copy without thinking. That sameness is also what would let a boundary wrapper absorb it one day, but that's its own log; the union earns its keep before any wrapper exists.
2. Expected failure is a value; throwing means a bug
"Out of stock" is not exceptional; it's a Tuesday. When a helper returns it, control flow reads top-to-bottom with no invisible exits.
The quieter win is what happens to the exception channel: it gets its meaning back. Anything that still throws is a genuine defect, and it surfaces as a loud 500 in the logs instead of being laundered into a polite client error by a well-meaning catch.
3. The compiler makes every caller look
Result<T> narrows, and narrowing is the only way in:
result.data // type error: might be a Failure
if (!result.ok) {
result.status // fine: narrowed to Failure
result.data // type error: failures carry no data
}Callers can't optimistically grab the payload and hope. It's the same "illegal states are unrepresentable" payoff as any tagged union: the type system enforces the reading order the contract needs.
4. Failures compose across layers
Every layer speaks the same union, so a helper that calls another helper forwards a refusal untouched:
const reserved = await reserveStock(items)
if (!reserved.ok) return reserved // code and status travel as-isNo re-wrapping, no lossy translation between ad-hoc shapes. The refusal keeps its original code and status from the depth where it was decided all the way to the boundary that reports it.
When not to reach for it
Don't wrap what can't fail: a function with no expected failure modes returns T, not Result<T>; reserving the union for real refusal keeps ok checks meaningful. And don't return what isn't expected: out-of-invariant conditions ("this row must exist by now") should throw and page you, not come back as a tidy 500-shaped value. The line is expectation, not severity. A sold-out item is expected; an order with a negative total is not.
The rule
Expected failures are values: every fallible helper returns one shared
Resultunion built only bysuccess()/error(), callers narrow onokbefore touching either branch, and throwing is reserved for actual bugs.