Skip to content
Chapter 8Lesson 2

Narrowing the catch and authoring domain errors

A TypeScript discipline for turning an unknown catch into a typed discrimination ladder, with custom Error subclasses that carry structured failure data.

Picture a chargeInvoice(invoiceId) Server Action. It calls Stripe to charge the card, writes an invoice row to Postgres, and enqueues a confirmation email. The whole thing sits inside one try/catch. Three different failures can arrive at that catch, and each one needs a different response.

A Stripe network error means Stripe’s API was unreachable. The catch should let it bubble: the operator log needs to know, and the framework boundary will render the global error page. A Stripe card_declined means the issuer rejected the card. The catch should turn it into “Your card was declined.” for the user. An AbortError means the user navigated away mid-charge. The catch should silently no-op, because there’s no user left to apologize to. Three failures, one catch (err), and err is typed unknown. How does the catch tell them apart, and how does it carry the original failure forward so the operator log gets the full chain?

The previous lesson left you with that exact question. Under strict plus useUnknownInCatchVariables, the catch parameter is unknown, so err.message is a compile error before any narrowing happens. This lesson installs the four moves that turn an unknown catch into a discrimination ladder: narrow with instanceof Error, discriminate with error.name (or a literal-typed name on a custom subclass), normalize with ensureError at vendor seams, and walk Error.cause when the structured log needs the chain. By the end you’ll have a canonical catch ladder for chargeInvoice that puts all four moves in order.

The cheapest narrow on an unknown value is instanceof Error. After the check, the compiler knows that everything on the standard Error surface is readable: message, name, stack, and cause. The two versions below show the same catch written the wrong way and the right way.

try {
await chargeInvoice(invoiceId);
} catch (err) {
log.error('charge failed', { message: err.message });
}

The catch parameter is unknown. The compiler refuses err.message because it has no proof that err is even an object, let alone one with a message field. Reading without narrowing is the same trap the previous lesson named for useUnknownInCatchVariables.

That’s the minimum surface the narrow gives you. message is the operator-facing string the throw site wrote. name is the constructor’s name by default: the literal 'Error' for new Error(...), or whatever a subclass set it to. stack is the trace captured at construction. cause is whatever the constructor’s second-argument options object set, or undefined if nothing was passed. Custom subclasses add their own typed fields on top, which is the subject of the section after next.

Error.isError() and the cross-realm gotcha

Section titled “Error.isError() and the cross-realm gotcha”

instanceof Error works inside a single JavaScript realm . The moment a thrown value crosses a realm boundary, such as a Web Worker, a vm sandbox, or the Next.js edge/Node split, the catching side and the throwing side hold different Error constructors, and err instanceof Error returns false on a real Error instance.

That feels exotic until you see it drawn out.

Realm A main thread
catch (err) {
  if (err instanceof Error) {
    /* false */
  }
}
Realm B worker thread
throw new Error('boom')
Realm A's Error constructor !== Realm B's Error constructor.
Each realm holds its own copy of every built-in constructor. instanceof Error checks identity against the catching side's Error — which isn't the constructor the throwing side used.

The 2026 fix is Error.isError(), a Stage 4 (ES2026) helper that ships unflagged in Node 24 LTS (the course’s pinned runtime, where V8 13.6 enables it) and in modern browsers. It checks the internal [[ErrorData]] slot instead of constructor identity, so it returns true for any real Error regardless of which realm constructed it.

try {
await chargeInvoice(invoiceId);
} catch (err) {
if (Error.isError(err)) {
log.error('charge failed', { message: err.message });
}
}

The shape is identical to the instanceof Error version; the difference is that Error.isError(err) is realm-safe. Inside a single realm, which covers most application code, instanceof Error is still fine. Reach for Error.isError() when the catch is on the receiving side of a worker, a vm context, an iframe, or the edge/Node split in Next.js.

Three sites where this matters in 2026 SaaS code:

  • Web Workers and MessageChannel boundaries. Rare in app code, common in observability libraries and heavy-compute offload.
  • The vm module in Node. Test runners, sandbox evaluators, anything that runs untrusted code in an isolated context.
  • The Next.js edge / Node runtime split. An error thrown in middleware (edge runtime) and caught in a Server Component (Node runtime) crosses realms.

Error.isError() isn’t always an option. You might be on an older runtime, or you might need a discriminator that works even when the catching code can’t import the error’s class. In both cases the error.name string is the durable fallback, and the next section covers it.

Every Error subclass sets name. Strings are values, not constructor references, so err.name === 'AbortError' works across realms, across module boundaries, and even when the class itself isn’t importable from the catching code. That portability is why name is the durable contract for cross-cutting errors.

Two sites make this concrete in 2026, both drawn from the cancellation surface the previous chapter installed.

try {
const data = await fetch(url, { signal: controller.signal });
return data;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return;
}
throw err;
}

AbortError is what fetch, AbortSignal.timeout, and most cancellable APIs throw when the signal aborts. In browsers it’s a DOMException; in Node it varies by API. The shape that always works is err.name === 'AbortError': the string is the contract, and the catching code returns silently because there’s no user left to surface anything to.

When the catch and the throw live in different modules, or worse, different bundles, the class may not be importable, but the name string always reads. That’s exactly why the next section’s custom subclasses pin name as a readonly ... as const literal: the literal is the discriminant, and the class is the implementation. Either narrow works because the two stay in lockstep.

When a failure rides the throw channel and the catch needs to read structured data, such as a Stripe decline code, a retry-after duration, or a tenant ID, reach for a small custom Error subclass. The 2026 shape is deliberately minimal: extend Error directly, give it a literal-typed name, add typed structured fields, and pass { cause } through to super. No abstract base classes, and no taxonomy trees.

Here’s the canonical shape with all four moves in one place. The annotations then walk through them one at a time.

lib/errors.ts
export class BillingError extends Error {
readonly name = 'BillingError' as const;
readonly code: 'card_declined' | 'insufficient_funds' | 'authentication_required';
constructor(
code: 'card_declined' | 'insufficient_funds' | 'authentication_required',
message: string,
options?: { cause?: unknown },
) {
super(message, options);
this.code = code;
}
}

Extends Error directly. No abstract base class, no AppError parent. Domain errors are a flat namespace: one class per domain concern (BillingError, RateLimitError, TenancyError), all extending Error. The taxonomy is the set of literal name values, not an inheritance tree. A base class would add shared behavior, but the catch only needs to read a tag.

lib/errors.ts
export class BillingError extends Error {
readonly name = 'BillingError' as const;
readonly code: 'card_declined' | 'insufficient_funds' | 'authentication_required';
constructor(
code: 'card_declined' | 'insufficient_funds' | 'authentication_required',
message: string,
options?: { cause?: unknown },
) {
super(message, options);
this.code = code;
}
}

The literal-typed name. This is the discriminant. Mark it readonly so the field can’t be reassigned after construction, and write 'BillingError' as const so the type is the string literal 'BillingError', not the wider string. Combined with instanceof Error, err.name === 'BillingError' narrows the type at the catch, including across realm or module boundaries where instanceof BillingError would fail. One thing to avoid: don’t write super('BillingError', ...) to set the name, because that puts 'BillingError' into message.

lib/errors.ts
export class BillingError extends Error {
readonly name = 'BillingError' as const;
readonly code: 'card_declined' | 'insufficient_funds' | 'authentication_required';
constructor(
code: 'card_declined' | 'insufficient_funds' | 'authentication_required',
message: string,
options?: { cause?: unknown },
) {
super(message, options);
this.code = code;
}
}

Structured fields. The class carries the data the catch needs to branch: here, code is the per-failure-mode discriminant inside the class. It’s typed as a string-literal union so the consumer’s switch is exhaustive. Other classes add fields freely: RateLimitError carries retryAfter: Temporal.Duration, and TenancyError carries expectedOrgId and actualOrgId. The catch reads err.code, not a parsed substring of err.message.

lib/errors.ts
export class BillingError extends Error {
readonly name = 'BillingError' as const;
readonly code: 'card_declined' | 'insufficient_funds' | 'authentication_required';
constructor(
code: 'card_declined' | 'insufficient_funds' | 'authentication_required',
message: string,
options?: { cause?: unknown },
) {
super(message, options);
this.code = code;
}
}

{ cause } passes through. The Error constructor’s second-argument options object accepts a cause field. Passing it through to super rather than ignoring it preserves the chain, which the next section’s rewrap pattern depends on. Without the passthrough, err.cause is undefined even when callers tried to set it.

1 / 1

That’s four moves in one class: flat inheritance, a literal name, typed structured fields, and a cause passthrough. Reach for this shape every time you need a domain error class.

A small note on naming. Use PascalCase matching the class name (BillingError, RateLimitError, TenancyError), one class per domain concern. The flat namespace lives at /lib/errors.ts, next to the Result helpers from the previous lesson and the ensureError normalizer you’ll meet two sections from now.

Error.cause is the 2026-current standard for saying “this failure was caused by that one.” It shows up in two patterns. The first is rewrap at the seam: a function catches a vendor error and throws a domain error carrying the original. The second is walk the chain: the structured logger reads cause recursively to log every link. You reach for the rewrap daily; the walk lives inside the logger.

The rewrap comes first.

try {
await stripe.charges.create({ amount, source: token });
} catch (e) {
if (e instanceof Stripe.errors.StripeCardError) {
throw new BillingError('card_declined', 'Card declined by issuer', {
cause: e,
});
}
throw e;
}

The vendor’s StripeCardError becomes the project’s BillingError. The user-facing branch in the action reads err instanceof BillingError && err.code === 'card_declined' and renders a message. The operator log walks err.cause and gets the full Stripe response, including the request ID, decline code, and network timing, without any string parsing. The chain is the contract.

Notice the catch parameter is e, not err. That’s deliberate: the previous lesson named err as the failure-factory helper from lib/result.ts (err({ code: 'CARD_DECLINED', ... })), and shadowing it inside the catch would force every reference back to fully-qualified imports. When the catch body uses the err factory, use e for the caught value. Otherwise err is fine.

The other pattern, walking the chain, is what the structured logger does behind the scenes.

const causes: Error[] = [];
let current: unknown = err;
while (current instanceof Error && !causes.includes(current)) {
causes.push(current);
current = current.cause;
}

The walker reads err.cause recursively, stops when cause is undefined, and also stops if it ever sees a cause it has already pushed. Cause cycles are rare, but they send an unguarded walker into an infinite loop. A later chapter owns the production logger; here the point is the pattern. Any catch that wants the full chain reads cause in a loop, not through a single err.cause.cause.cause access, because that access throws the moment the chain is shorter than the dots assume.

One detail is worth calling out. Setting cause to a non-Error value (a string, a plain object, null) is legal but loses the structured chain. Inside the project’s own code, cause is always an Error or undefined. Vendor seams may set it to anything, which is exactly why the walker checks current instanceof Error before reading current.cause.

The “only throw Error” rule from the previous lesson is absolute inside the project’s own code. At third-party seams, such as legacy callback adapters, some browser APIs, and certain SDK rejections, a thrown value may be a string, a plain object, or even null. The ensureError helper normalizes any unknown into an Error so the rest of the catch can treat it uniformly.

lib/errors.ts
export const ensureError = (value: unknown): Error =>
value instanceof Error
? value
: new Error(
typeof value === 'string' ? value : JSON.stringify(value),
{ cause: value },
);

If value is already an Error, return it untouched. Otherwise, wrap it in a fresh Error whose message is a sensible string and whose cause is the original value (so the structured logger can still see what came in). The catch becomes catch (err) { const error = ensureError(err); ... } and the rest of the block treats error as Error safely.

ensureError lives at /lib/errors.ts next to the custom subclasses, imported at every catch that touches a vendor seam. Inside the project’s own code, where the “only throw Error” rule holds, the catch doesn’t need it, because instanceof Error is enough. The call lives at the bottom of the canonical catch ladder in the next section, where it acts as the catch-all for the case where a third party threw a string, after the specific-subclass and error.name branches have already had their turn.

With all four moves in hand, you can assemble them into one catch. The canonical 2026 catch reads top to bottom, from most specific to least: specific subclasses first (the cheapest, most type-safe narrows), then error.name for cross-realm errors the catch can recognize by string, then a generic Error branch for everything else the project threw, then ensureError as the final catch-all for vendors that broke the rule.

The lesson opened with the chargeInvoice Server Action. Here’s where it all comes together.

// app/(app)/invoices/actions.ts
export const chargeInvoice = async (invoiceId: string) => {
try {
await processCharge(invoiceId);
return ok({ chargedAt: Temporal.Now.instant() });
} catch (e) {
if (e instanceof BillingError) {
switch (e.code) {
case 'card_declined':
return err({ code: 'CARD_DECLINED', userMessage: 'Your card was declined.' });
case 'insufficient_funds':
return err({ code: 'INSUFFICIENT_FUNDS', userMessage: 'Not enough funds.' });
case 'authentication_required':
return err({ code: '3DS_REQUIRED', userMessage: 'Additional verification needed.' });
}
}
if (e instanceof Error && e.name === 'AbortError') {
return;
}
if (e instanceof Error) {
throw e;
}
throw ensureError(e);
}
};

Specific subclass first. The instanceof BillingError narrow gives the typed e.code discriminant. The switch runs the per-code branches and converts each one into a Result.err with a stable code and a user-facing message: the channel-conversion pattern from the previous lesson, applied at the seam. The switch only covers the BillingError cases; anything that isn’t a BillingError falls through to the next branch in the ladder.

// app/(app)/invoices/actions.ts
export const chargeInvoice = async (invoiceId: string) => {
try {
await processCharge(invoiceId);
return ok({ chargedAt: Temporal.Now.instant() });
} catch (e) {
if (e instanceof BillingError) {
switch (e.code) {
case 'card_declined':
return err({ code: 'CARD_DECLINED', userMessage: 'Your card was declined.' });
case 'insufficient_funds':
return err({ code: 'INSUFFICIENT_FUNDS', userMessage: 'Not enough funds.' });
case 'authentication_required':
return err({ code: '3DS_REQUIRED', userMessage: 'Additional verification needed.' });
}
}
if (e instanceof Error && e.name === 'AbortError') {
return;
}
if (e instanceof Error) {
throw e;
}
throw ensureError(e);
}
};

Cross-realm name check next. AbortError may arrive from a different realm, such as an edge-runtime middleware that cancelled or a worker that aborted, and the error.name string is the portable discriminant. The combined instanceof Error && e.name === '...' shape works inside the same realm; for true cross-realm code, swap instanceof Error for Error.isError(e).

// app/(app)/invoices/actions.ts
export const chargeInvoice = async (invoiceId: string) => {
try {
await processCharge(invoiceId);
return ok({ chargedAt: Temporal.Now.instant() });
} catch (e) {
if (e instanceof BillingError) {
switch (e.code) {
case 'card_declined':
return err({ code: 'CARD_DECLINED', userMessage: 'Your card was declined.' });
case 'insufficient_funds':
return err({ code: 'INSUFFICIENT_FUNDS', userMessage: 'Not enough funds.' });
case 'authentication_required':
return err({ code: '3DS_REQUIRED', userMessage: 'Additional verification needed.' });
}
}
if (e instanceof Error && e.name === 'AbortError') {
return;
}
if (e instanceof Error) {
throw e;
}
throw ensureError(e);
}
};

Generic Error branch. Everything that’s an Error but isn’t a BillingError or an AbortError is operational: Stripe’s API is down, the database is unreachable, or an invariant tripped. Rethrow and let the framework boundary handle it. The boundary owns the user-versus-operator split, which a later unit will deepen.

// app/(app)/invoices/actions.ts
export const chargeInvoice = async (invoiceId: string) => {
try {
await processCharge(invoiceId);
return ok({ chargedAt: Temporal.Now.instant() });
} catch (e) {
if (e instanceof BillingError) {
switch (e.code) {
case 'card_declined':
return err({ code: 'CARD_DECLINED', userMessage: 'Your card was declined.' });
case 'insufficient_funds':
return err({ code: 'INSUFFICIENT_FUNDS', userMessage: 'Not enough funds.' });
case 'authentication_required':
return err({ code: '3DS_REQUIRED', userMessage: 'Additional verification needed.' });
}
}
if (e instanceof Error && e.name === 'AbortError') {
return;
}
if (e instanceof Error) {
throw e;
}
throw ensureError(e);
}
};

ensureError as the catch-all. If e isn’t an Error at all, because a third-party adapter threw a string, ensureError normalizes it, and then you rethrow. Two things land at the boundary: a real Error instance (so the boundary’s narrowing reads) and the original value preserved on cause (so the operator log can still see exactly what came in).

1 / 1

The order of the branches matters. The ladder reads top to bottom because the narrows get less specific as you descend. instanceof BillingError is the most specific, instanceof Error matches everything BillingError would have matched and more, and ensureError matches anything. Putting instanceof Error before instanceof BillingError would swallow BillingErrors into the generic branch and lose the discriminant. The trailing ensureError exists only for the case where a third party broke the rules; inside the project’s own code, it would never fire.

The previous chapter installed Promise.any as the “first to succeed” combinator. When all inputs reject, Promise.any rejects with an AggregateError that carries every individual rejection on an errors array.

try {
const winner = await Promise.any([fetchPrimary(), fetchSecondary(), fetchTertiary()]);
return winner;
} catch (e) {
if (e instanceof AggregateError) {
log.error('all sources failed', { errors: e.errors.map((err) => err.message) });
}
throw e;
}

Narrow with e instanceof AggregateError, then read e.errors to decide on the response: log every rejection, surface a “no sources available” message, or trigger a fallback. Each entry in errors is itself a value the catch can narrow with the same ladder (specific subclass, error.name, generic Error).

There is one last rule to cover before the practice. The message field on an Error is for operators, not users. It carries the technical detail the structured log needs: the SQL constraint name, the Stripe decline code, the Zod path, the request ID.

The user-facing message lives elsewhere. The UI maps the Result.err.code discriminant from the previous lesson to a translation key, and at framework boundaries the error.tsx boundary’s user-visible chrome carries the apology. So the rule is simple: don’t render err.message to users. The technical detail leaks information and reads as gibberish, whereas the localized, audience-appropriate message lives on the code the type system already forced the caller to inspect.

A later chapter owns the two-audience split: the wrapper that produces the user-visible message from the code and the operator-visible log line from err.message plus err.cause. The rule lands here so you keep err.message out of the UI from the start.

Two exercises follow. The first checks discrimination, whether you can spot a broken catch when you see one. The second checks authoring, whether you can write a custom subclass and the catch that reads it.

Three files, one defect each. Leave inline review comments naming what’s wrong. The grader scores each comment against the issue’s kernel.

Three catches, one defect each. Leave an inline comment on the line that breaks the lesson's narrowing reflex. Click any line to leave a review comment, then press Submit review.

lib/billing/charge.ts
try {
await stripe.charges.create({ amount });
} catch (err) {
log.error(err.message);
throw err;
}

Exercise 2: Author a RateLimitError and catch it

Section titled “Exercise 2: Author a RateLimitError and catch it”

Author a RateLimitError subclass with a literal name, a retryAfter field, and { cause } passthrough. Then write the catch that discriminates it from a generic Error.

Author the RateLimitError class with a literal-typed name, a retryAfter field, and a {cause} passthrough. Then write the catch that discriminates it from a generic Error.

    Reveal solution
    export class RateLimitError extends Error {
    readonly name = 'RateLimitError' as const;
    readonly retryAfter: number;
    constructor(retryAfter: number, message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.retryAfter = retryAfter;
    }
    }
    export const callApi = async (
    fn: () => Promise<unknown>,
    ): Promise<{ ok: true } | { ok: false; retryAfter: number }> => {
    try {
    await fn();
    return { ok: true };
    } catch (e) {
    if (e instanceof RateLimitError) {
    return { ok: false, retryAfter: e.retryAfter };
    }
    throw e;
    }
    };

    Four moves: extends Error directly, readonly name = 'RateLimitError' as const, typed retryAfter field, super(message, options) for the cause passthrough. The catch is the canonical specific-subclass-first shape — narrow with instanceof RateLimitError, read the typed field, fall through to a rethrow for anything else.

    The unknown catch is a discrimination ladder. Narrow with instanceof Error (or Error.isError() across realms), discriminate with error.name for cross-realm or cross-module errors, normalize with ensureError at vendor seams, and walk Error.cause when the structured log needs the chain. Custom subclasses pin a literal-typed name and structured fields so the catch reads tagged data instead of parsed substrings.

    That closes the error substrate. A later chapter turns the “err.message is for operators” rule into the wrapper that produces both a user-visible and an operator-visible message from one throw. Another turns the Error.cause walker into the structured-log pipeline. Both build on the moves this lesson installed.