Errors are values, handled explicitly. TypeScript erases types, so a thrown value has no type the compiler tracks: catch always hands you unknown, and a signature never tells you what flies out. This chapter closes that gap. Model failures as typed Error subclasses with a mandatory cause chain, narrow every catch, and reach for an opt-in Result union where a failure is part of the contract. Programmer errors crash fast; operational errors are handled.
/** Base for every payment failure. Carries a correlation id for the log trail. */
export class PaymentError extends Error {
readonly correlationId: string;
constructor(message: string, correlationId: string, options?: ErrorOptions) {
super(message, options);
this.correlationId = correlationId;
this.name = new.target.name; // every subclass reports its own name
}
}
export class CardDeclinedError extends PaymentError {
readonly cardLast4: string;
readonly declineCode: string;
constructor(cardLast4: string, declineCode: string, correlationId: string) {
super(`card ****${cardLast4} declined: ${declineCode}`, correlationId);
this.cardLast4 = cardLast4;
this.declineCode = declineCode;
}
}
export class GatewayUnavailableError extends PaymentError {}
/** Boundary: wraps the gateway's failures, never lets a raw one leak upward. */
export async function chargeCard(card: Card, amount: Cents, correlationId: string): Promise<Receipt> {
invariant(amount > 0, `amount must be positive, got ${amount}`); // 8.7 programmer error
let response: GatewayResponse;
try {
response = await gateway.submit(card.token, amount);
} catch (e: unknown) {
throw new GatewayUnavailableError('payment gateway unreachable', correlationId, { cause: toError(e) });
}
switch (response.status) {
case 'approved': return response.receipt;
case 'declined': throw new CardDeclinedError(card.last4, response.declineCode, correlationId);
default: return assertNever(response.status); // 8.9 exhaustive; a new status fails to compile
}
}This module throws rather than returning a Result (8.6), and the choice holds throughout. PaymentError is a typed hierarchy with context fields (8.1); cause carries the gateway failure forward (8.2); the catch narrows unknown through toError (8.3); the boundary wraps so the domain never sees a raw fault (8.5); invariant splits the programmer error from the operational ones (8.7); the switch is exhaustive (8.9). The calling HTTP handler logs once and maps each error type to a status code.
Reasoning, step by step:
- A bare
throw new Error('payment failed')gives a debugger a string and nothing else: no order id, no amount, no correlation id. The context died at the throw site. - Subclass
Errorper domain, with a root (PaymentError) and specific leaves (CardDeclinedError). The caller catches the root for generic handling or a leaf for precise recovery. - Carry the identifying inputs as
readonlyfields — ids, the offending input, thecorrelationIdthat ties the failure to a request. These survive serialization and show up in structured logs. - Set
this.name = new.target.namein the base constructor so every subclass reports its own class name in stack traces without restating it. Keep the tree two levels deep; a five-level error hierarchy navigates no better than a two-level one.
export class LineItemInvalidError extends OrderError {
readonly lineIndex: number;
readonly quantity: number;
constructor(orderId: string, lineIndex: number, quantity: number) {
super(`order ${orderId}: line ${lineIndex} has invalid quantity ${quantity}`, orderId);
this.lineIndex = lineIndex;
this.quantity = quantity;
}
}Enforcement: review; domain failures extend Error and declare context fields, no bare new Error.
Reasoning, step by step:
- When you catch one error and throw another, the original is the evidence. Drop it and the new stack trace starts at your rethrow; the actual fault is gone. A chain without
causeis amnesia. Erroraccepts{ cause }as its second argument (ES2022) and V8 prints the chain. Always pass it:throw new PaymentDeclinedError(msg, { cause: e }).- The cause must be an
Error, so run the caught value throughtoError(8.3) first. Inner layers wrap withcause; the boundary error explains what, the cause explains why.
try {
await db.insert(order);
} catch (e: unknown) {
throw new OrderPersistFailedError(order.id, { cause: toError(e) }); // chain preserved
}Enforcement: review; every wrap-and-rethrow passes { cause }.
Reasoning, step by step:
throwaccepts any value, so acatchbinding isunknown(useUnknownInCatchVariables, on in strict mode). Treating it asErroris a lie the compiler stopped catching.- Narrow before use. For the common "I need an
Errorto chain or log" case, funnel through one shared helper defined once and imported everywhere — and the funnel must never throw from inside acatch, so guard the stringify:String(e)itself throws on a null-prototype object or a value whosetoString/Symbol.toPrimitivethrows.
export function toError(e: unknown): Error {
if (e instanceof Error) return e;
try {
return new Error(String(e));
} catch {
return new Error('non-stringifiable thrown value', { cause: e });
}
}- For richer narrowing that distinguishes your own error types, use
instanceofagainst the specific class — never duck-typing on.messageor a string.code.
try {
return parseConfig(raw);
} catch (e: unknown) {
if (e instanceof SyntaxError) throw new ConfigInvalidError(path, { cause: e });
throw toError(e); // anything else: normalize and let it fly
}Enforcement: useUnknownInCatchVariables (strict); no-explicit-any blocks catch (e: any); toError in a shared module.
Reasoning, step by step:
- An error is information. An empty
catch {}, or acatchthat logs and continues as if nothing happened, discards it and ships corrupt state forward. - A
catchblock has exactly three honest endings: handle the failure (recover to a known-good state), wrap-and-rethrow withcause(8.2), or don't catch at all and let it fly. "Log and continue" is none of these; if you cannot recover, rethrow, and logging belongs at the boundary (8.5). - The rare deliberate ignore, a best-effort cleanup that may legitimately fail, is narrowed to the one expected error and carries a comment saying why the failure is tolerable.
// bad — silent swallow, corrupt state continues
try { await reserve(seat); } catch { /* ignored */ }
// good — tolerable failure, narrowed and explained
try {
await tempFile.delete();
} catch (e: unknown) {
if (!(e instanceof FileNotFoundError)) throw e; // already gone is fine; anything else is not
}Enforcement: no-empty (forbids empty blocks); review for log-and-continue.
Reasoning, step by step:
- A
PrismaClientKnownRequestErrorfrom the repository must not surface in domain logic — the domain does not know about the ORM. Each layer translates the one below into its own vocabulary, wrapping withcauseso nothing is lost. - Logging is a boundary concern. If every layer logs the same failure, one error becomes five log lines and the real one drowns. Inner layers add context to the error object; they do not log.
- The outermost boundary (HTTP handler, queue consumer, CLI entry) logs once with the full
causechain and thecorrelationId, then decides the representation: a 500 with an opaque id, a non-zero exit. A typed hierarchy (8.1) makes the error-to-response mapping aswitch, not a cascade ofinstanceof.
export async function handleCharge(req: Request): Promise<Response> {
try {
return Response.json(await chargeCard(req.card, req.amount, req.correlationId));
} catch (e: unknown) {
logger.error('charge failed', { err: e, correlationId: req.correlationId }); // logged once, here
if (e instanceof CardDeclinedError) return Response.json({ code: 'declined' }, { status: 402 });
return Response.json({ correlationId: req.correlationId }, { status: 500 });
}
}Enforcement: review; logging calls in catch blocks live only at boundary modules.
Reasoning, step by step:
- Where failure is expected and part of the contract (a parser, a payment pipeline, anything whose every failure mode must appear in the signature), make it a value the caller cannot ignore. Define the union once, frozen, with an
okdiscriminant —Object.freezeis shallow, so it locks the wrapper'sok/value/errorslots while the payload's own immutability stays the payload's concern (chapter 03 §3.10):
export interface Ok<T> { readonly ok: true; readonly value: T; }
export interface Err<E> { readonly ok: false; readonly error: E; }
export type Result<T, E> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => Object.freeze({ ok: true, value });
export const err = <E>(error: E): Err<E> => Object.freeze({ ok: false, error });- The
okdiscriminant drives exhaustive narrowing:if (r.ok)gives the compilervalue, theelsegiveserror. No unwrap can skip the failure branch. - A module either throws or returns
Result, never both. Mixing forces the caller to narrow the union and wrap atryaround it — the worst of each style. Pick one per module and hold it. - The transition happens at a module boundary: a throwing dependency becomes a
Resultat the adapter that calls it, and aResultis unwrapped (or rethrown) at the edge of the module that produced it. (This ports the Python guide's §8.11 Result discipline.)
function parseAmount(raw: string): Result<Cents, 'empty' | 'not-a-number' | 'negative'> {
if (raw.trim() === '') return err('empty');
const n = Number(raw);
if (Number.isNaN(n)) return err('not-a-number');
return n < 0 ? err('negative') : ok(n as Cents);
}
const parsed = parseAmount(input);
if (!parsed.ok) return reject(parsed.error); // failure branch cannot be skipped
charge(parsed.value);Enforcement: review; one error style per module, Result shapes frozen and readonly.
Reasoning, step by step:
- A programmer error is a bug: a violated precondition, an unreachable branch reached, an impossible state. Retrying cannot fix it — the code is wrong. It must crash loudly and close to the fault so the bug is found.
- An operational error is an expected failure of a correct program: a declined card, a timed-out request, a missing file. It is part of the contract and must be handled as a typed
Error(8.1) or aResult(8.6). - Assert programmer errors with
invariant(see chapter 05); it throws on a false condition and narrows the type for the compiler, and an impossibleswitchbranch isassertNever. Never demote a bug to a handled error; that hides it. This is the Tiger split: assertions that crash for your mistakes, values that are handled for the world's expected failures.
function applyDiscount(price: Cents, pct: number): Cents {
invariant(pct >= 0 && pct <= 100, `pct out of range: ${pct}`); // bug if violated — crash
return (price * (100 - pct)) / 100 as Cents;
}
// an out-of-stock item is operational: a typed error or a Result, never an invariant.Enforcement: review; invariant/assertNever for bugs, typed errors or Result for expected failures.
Reasoning, step by step:
'validation failed'is useless.`order ${id}: line ${i} has negative quantity ${qty}`is debuggable. Include the identifying inputs the reader cannot otherwise see, not just the symptom. A message is a public API: it travels into logs, stack traces, alerting, and sometimes a user-facing surface.- Never interpolate secrets — tokens, credentials, API keys, full PANs, raw PII. Mask to the minimum identifying fragment:
card ****1234, not the full number. The masking rules in security.md apply. - Put structured fields on the error object (8.1), not only in the message string, so a log aggregator can index them without parsing prose.
// good — identifying context, secret masked
throw new CardDeclinedError(card.last4, code, correlationId);
// bad — leaks the full token into every log that touches this error
throw new Error(`charge failed for token ${card.fullToken}`);Enforcement: review; secret-scanning in CI; masking helpers from security.md.
Reasoning, step by step:
- An exception is for the exceptional. Using
throw/catchto signal an ordinary "not found" or "no" conflates a result with a failure and pays a stack-trace capture for a routine branch. - Absence is
undefined. A lookup that may miss returnsT | undefined, and the caller narrows with?./??(chapter 07). The may-miss variant isget<Noun>(): T | undefined, documented by its signature (chapter 02); the asserting variant keeps the same verb and throws when absent. (Array.prototype.findbelow is the unrelated array method, not this resource verb.) - A yes/no question returns a
boolean.hasAccess(user): booleanreturnsfalsefor "no"; it does not throw anAccessDeniedErrorthe caller wraps atryaround to read. Reservethrowfor genuine failures (8.7) andResult(8.6) for expected-failure contracts; neither replaces a predicate or anundefined.
const user = users.find((u) => u.id === id); // good — absence is a value: User | undefined
if (user === undefined) return notFound();
try { return getUserOrThrow(id); } catch { return notFound(); } // bad — control flow via exceptionsEnforcement: review; T | undefined/boolean for routine branches, no try around a throwing lookup used as a predicate.
Reasoning, step by step:
- A thrown error is invisible in the signature —
function chargeCard(...): Promise<Receipt>admits nothing aboutCardDeclinedError. An undocumented throw is hidden behaviour the caller discovers in production. - A public function makes its failures part of its contract one of two ways: a
@throwsTSDoc tag per error type the caller might reasonably catch, or aResultreturn type (8.6) that puts the failures in the type itself. - Prefer the
Resultsignature where it fits; it cannot drift from the implementation the way a comment can. With@throws, list only the errors a caller would act on, not everyErrorthat could theoretically escape, and keep it in step with the code — a new throw is a contract change (see chapter 14).
/**
* Charges a card.
* @throws {CardDeclinedError} the issuer declined the charge — retry with another card.
* @throws {GatewayUnavailableError} the gateway was unreachable — safe to retry.
*/
export function chargeCard(card: Card, amount: Cents, correlationId: string): Promise<Receipt>;Enforcement: review; @throws on throwing publics or a Result signature, checked against the implementation.
Reasoning, step by step:
- When N independent operations run together and several fail, reporting only the first throws away the other N−1. A batch that fails on items 3, 7, and 12 must say so, all of them, not stop at item 3.
- Run the operations with
Promise.allSettled(notPromise.all, which rejects on the first failure and abandons the rest), then collect the rejections. - If any failed, throw a single
AggregateErrorholding every cause, with a message stating how many of how many failed. This pairs with the bounded fan-out rules in chapter 09: the concurrency stays bounded, and aggregation is how its partial failures surface.
const results = await Promise.allSettled(orders.map((o) => persist(o)));
const failures = results.flatMap((r) => (r.status === 'rejected' ? [toError(r.reason)] : []));
if (failures.length > 0) {
throw new AggregateError(failures, `${failures.length}/${orders.length} orders failed to persist`);
}Enforcement: review; Promise.allSettled + AggregateError for independent fan-out, not first-failure-wins.
invariant,assertNever, and assertion density: chapter 05.- Discriminated unions and making illegal states unrepresentable: chapter 06.
?./??for absence, exhaustive narrowing: chapter 07.Promise.allSettled,AbortSignal.timeout(), bounded fan-out: chapter 09.@throwsdocumentation and contract changes: chapter 14.- Secret masking in messages and logs: security guide.