Return a Result Type for Failures You Expect
Stop throwing exceptions for failures you know will happen. When a function parses user input, calls an external API, or reads a file, failure isn't exceptional — it's a normal, expected outcome. Throwing for it hides the error path from the caller: nothing in the function's type says it can fail, so nothing forces anyone to handle it. Return a Result instead — either ok with a value or err with a typed error — and the compiler stops letting people forget.
Why throw hides expected failures
A thrown exception is invisible in the type system. function parse(s: string): User looks total — it always returns a User. But if it throws on bad input, every caller is silently on the hook for a try/catch that TypeScript will never remind them to write. The happy path compiles cleanly and ships; the error path is discovered in production.
Exceptions are for the truly exceptional — the bugs and invariant violations you don't expect and can't sensibly recover from at the call site. Expected failures — validation, network, parsing, missing records — belong in the return type, where they're impossible to ignore.
Return a Result instead
A Result is a union of two shapes: success carrying a value, or failure carrying a typed error.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number, "not_a_number" | "negative"> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: "not_a_number" };
if (n < 0) return { ok: false, error: "negative" };
return { ok: true, value: n };
}
const r = parseAge(raw);
if (r.ok) {
use(r.value); // r.value is a number here
} else {
report(r.error); // r.error is the typed error here
}
The ok field is the discriminant. Because it's a literal (true / false), TypeScript narrows the union the moment you check it: inside the if, only value exists; inside the else, only error. You can't read r.value without first proving r.ok — the compiler won't let you skip the failure branch.
When to use which
| Failure kind | Example | Signal it with | |
|---|---|---|---|
| Expected & recoverable | Bad input, 404, timeout, parse error | Return a Result | |
| Unexpected / bug | Null invariant broken, "impossible" state | Throw (or assert) | |
| Absence, no detail needed | Lookup miss with one obvious cause | `T \ | undefined` |
Power moves
- Use a library so you don't hand-roll it. neverthrow ships
ok()/err()constructors plus combinators —map,mapErr,andThen(chain fallible steps, short-circuiting on the first error), andmatch(okFn, errFn)to collapse both branches.ResultAsyncwrapsPromise<Result>for async flows. - Make errors a discriminated union, not
Error.{ type: "not_found" } | { type: "forbidden" }lets callersswitchonerror.typeand get exhaustiveness checking — add a case and every unhandledswitchfails to compile. - Keep throwing at the boundary. Convert to exceptions only at the top of a request handler where you translate the typed error into an HTTP status. The core stays total; the edge stays ergonomic.
- This is Rust's model. Rust's
Result<T, E>is the same idea enforced by the language — the pattern predates and outlives any one library.
Resources
- TypeScript Handbook — Narrowing & discriminated unions — how the
okliteral narrows each branch. - neverthrow — Type-safe errors for JS & TypeScript —
ok/err,map,andThen,match,ResultAsync. - Rust standard library —
Result<T, E>— the return-typed-error model this borrows from.
<div class="cta"> Building an AI feature? Yeda AI designs, audits, and ships production LLM systems. </div>