← Concepts & practices
Pattern Errors, results, and recovery

Class-based error hierarchies

Give a failure a type the caller can recognize.

A catch block is more useful when it can tell “the note is gone” from “the service is down.” Subclassing makes those branches readable inside one TypeScript module. Then the boundary moves: what happens when the class is duplicated, minified, wrapped, or turned into JSON?

The judgment to keep

Use a local hierarchy when constructor identity is a real contract; use a stable code and validated payload when the failure must travel.

TypeScript One error family · one language
Start with the caller

One failed read, three honest next steps.

A note editor loads note 42. The note may be missing, the service may be temporarily unavailable, or the current user may not have access. Each failure needs a different next step: show an empty state, offer a retry, or ask for access.

A plain Error gives the caller a message and a stack. The message is for people; the branch needs a contract it can inspect. A class-based error hierarchy gives related failures a base type and gives each case its own subclass and fields.

The pattern is not “make every error a class.” It is “make a family of thrown failures narrowable by type.”

Read the familiar starting pointTypeScript · subclasses of Error
errors.ts · hierarchy
/** The familiar form: catch a base class, then narrow to subclasses. */
export class NoteError extends Error {
	constructor(message: string, options?: ErrorOptions) {
		super(message, options);
		this.name = new.target.name;
		Object.setPrototypeOf(this, new.target.prototype);
	}
}

export class NoteMissingError extends NoteError {
	readonly noteId: number;

	constructor(noteId: number, options?: ErrorOptions) {
		super(`Note ${noteId} was not found.`, options);
		this.noteId = noteId;
	}
}

export class ServiceUnavailableError extends NoteError {
	readonly retryAfterSeconds: number;

	constructor(retryAfterSeconds: number, options?: ErrorOptions) {
		super('The notes service is unavailable.', options);
		this.retryAfterSeconds = retryAfterSeconds;
	}
}

export class AccessDeniedError extends NoteError {
	readonly requiredRole: string;

	constructor(requiredRole: string, options?: ErrorOptions) {
		super(`You need ${requiredRole} access to this note.`, options);
		this.requiredRole = requiredRole;
	}
}

The base class restores the prototype explicitly, sets name, and accepts an optional cause. The subclasses add only the data their branches need. The caller still decides whether the failure is expected, retriable, or an unknown defect.

Give the family a shape

Start with instanceof. Then ask what it depends on.

The canonical handler walks a preserved cause chain and narrows each subclass. It reads the retry delay from ServiceUnavailableError and does not ask every caller to parse a message.

The twin keeps the same caller decisions but replaces constructor identity with an explicit code. Its guard is deliberately small and its toJSON method exposes only fields the public contract intends to carry.

Canonical

Subclass + instanceof

Readable local narrowing. The constructor is part of the contract.

errors.ts
export function byHierarchy(error: unknown): Decision {
	for (const cause of causes(error)) {
		if (cause instanceof NoteMissingError) return missing();
		if (cause instanceof ServiceUnavailableError) return retry(cause.retryAfterSeconds);
		if (cause instanceof AccessDeniedError) return access();
	}
	return fallback();
}
Twin

Stable code + static guard

Explicit identity. The code and payload can survive a copy or a wire boundary.

errors.ts · stable code twin
type AppErrorData = Readonly<{
	code: FailureCode;
	noteId?: number;
	retryAfterSeconds?: number;
	requiredRole?: string;
}>;

export class AppError extends Error {
	readonly code: FailureCode;
	readonly noteId?: number;
	readonly retryAfterSeconds?: number;
	readonly requiredRole?: string;

	constructor(data: AppErrorData, options?: ErrorOptions) {
		super(messages[data.code], options);
		this.name = 'AppError';
		this.code = data.code;
		this.noteId = data.noteId;
		this.retryAfterSeconds = data.retryAfterSeconds;
		this.requiredRole = data.requiredRole;
		Object.setPrototypeOf(this, new.target.prototype);
	}

	/** Structural recognition: validate every field a branch will trust, not just the name. */
	static is(value: unknown): value is AppError {
		if (typeof value !== 'object' || value === null) return false;
		const candidate = value as Record<string, unknown>;
		const optional = (field: string, type: 'number' | 'string') =>
			candidate[field] === undefined || typeof candidate[field] === type;
		return (
			candidate.name === 'AppError' &&
			(candidate.code === 'note-missing' ||
				candidate.code === 'service-unavailable' ||
				candidate.code === 'access-denied') &&
			optional('noteId', 'number') &&
			optional('retryAfterSeconds', 'number') &&
			optional('requiredRole', 'string')
		);
	}

	toJSON() {
		return {
			name: this.name,
			code: this.code,
			message: this.message,
			noteId: this.noteId,
			retryAfterSeconds: this.retryAfterSeconds,
			requiredRole: this.requiredRole
		};
	}
}

export function byCode(error: unknown): Decision {
	for (const cause of causes(error)) {
		if (!AppError.is(cause)) continue;
		return decisionFor(cause.code, cause.retryAfterSeconds ?? null);
	}
	if (AppError.is(error)) return decisionFor(error.code, error.retryAfterSeconds ?? null);
	return fallback();
}
See the complete programCopyable source plus invocation
errors.ts
export type FailureCode = 'note-missing' | 'service-unavailable' | 'access-denied';
export type Representation = 'hierarchy' | 'stable-code';
export type Environment = 'same-module' | 'duplicate-module' | 'json';

export type Decision = Readonly<{
	action: 'show empty state' | 'offer retry' | 'ask for access' | 'use generic fallback';
	retryAfterSeconds: number | null;
}>;

export type Observation = Readonly<{
	representation: Representation;
	code: FailureCode;
	environment: Environment;
	recognized: boolean;
	path: 'branch' | 'fallback';
	decision: Decision;
	detail: string;
}>;

const messages: Record<FailureCode, string> = {
	'note-missing': 'Note 42 was not found.',
	'service-unavailable': 'The notes service is unavailable.',
	'access-denied': 'You need editor access to this note.'
};

function missing(): Decision {
	return { action: 'show empty state', retryAfterSeconds: null };
}

function retry(seconds: number | null): Decision {
	return { action: 'offer retry', retryAfterSeconds: seconds };
}

function access(): Decision {
	return { action: 'ask for access', retryAfterSeconds: null };
}

function fallback(): Decision {
	return { action: 'use generic fallback', retryAfterSeconds: null };
}

function decisionFor(code: FailureCode, retryAfterSeconds: number | null): Decision {
	switch (code) {
		case 'note-missing':
			return missing();
		case 'service-unavailable':
			return retry(retryAfterSeconds);
		case 'access-denied':
			return access();
	}
}

function causes(error: unknown): Generator<Error> {
	return (function* () {
		const seen = new Set<Error>();
		let current = error;
		while (current instanceof Error && !seen.has(current)) {
			seen.add(current);
			yield current;
			current = current.cause;
		}
	})();
}

/** The familiar form: catch a base class, then narrow to subclasses. */
export class NoteError extends Error {
	constructor(message: string, options?: ErrorOptions) {
		super(message, options);
		this.name = new.target.name;
		Object.setPrototypeOf(this, new.target.prototype);
	}
}

export class NoteMissingError extends NoteError {
	readonly noteId: number;

	constructor(noteId: number, options?: ErrorOptions) {
		super(`Note ${noteId} was not found.`, options);
		this.noteId = noteId;
	}
}

export class ServiceUnavailableError extends NoteError {
	readonly retryAfterSeconds: number;

	constructor(retryAfterSeconds: number, options?: ErrorOptions) {
		super('The notes service is unavailable.', options);
		this.retryAfterSeconds = retryAfterSeconds;
	}
}

export class AccessDeniedError extends NoteError {
	readonly requiredRole: string;

	constructor(requiredRole: string, options?: ErrorOptions) {
		super(`You need ${requiredRole} access to this note.`, options);
		this.requiredRole = requiredRole;
	}
}

export function byHierarchy(error: unknown): Decision {
	for (const cause of causes(error)) {
		if (cause instanceof NoteMissingError) return missing();
		if (cause instanceof ServiceUnavailableError) return retry(cause.retryAfterSeconds);
		if (cause instanceof AccessDeniedError) return access();
	}
	return fallback();
}

type AppErrorData = Readonly<{
	code: FailureCode;
	noteId?: number;
	retryAfterSeconds?: number;
	requiredRole?: string;
}>;

export class AppError extends Error {
	readonly code: FailureCode;
	readonly noteId?: number;
	readonly retryAfterSeconds?: number;
	readonly requiredRole?: string;

	constructor(data: AppErrorData, options?: ErrorOptions) {
		super(messages[data.code], options);
		this.name = 'AppError';
		this.code = data.code;
		this.noteId = data.noteId;
		this.retryAfterSeconds = data.retryAfterSeconds;
		this.requiredRole = data.requiredRole;
		Object.setPrototypeOf(this, new.target.prototype);
	}

	/** Structural recognition: validate every field a branch will trust, not just the name. */
	static is(value: unknown): value is AppError {
		if (typeof value !== 'object' || value === null) return false;
		const candidate = value as Record<string, unknown>;
		const optional = (field: string, type: 'number' | 'string') =>
			candidate[field] === undefined || typeof candidate[field] === type;
		return (
			candidate.name === 'AppError' &&
			(candidate.code === 'note-missing' ||
				candidate.code === 'service-unavailable' ||
				candidate.code === 'access-denied') &&
			optional('noteId', 'number') &&
			optional('retryAfterSeconds', 'number') &&
			optional('requiredRole', 'string')
		);
	}

	toJSON() {
		return {
			name: this.name,
			code: this.code,
			message: this.message,
			noteId: this.noteId,
			retryAfterSeconds: this.retryAfterSeconds,
			requiredRole: this.requiredRole
		};
	}
}

export function byCode(error: unknown): Decision {
	for (const cause of causes(error)) {
		if (!AppError.is(cause)) continue;
		return decisionFor(cause.code, cause.retryAfterSeconds ?? null);
	}
	if (AppError.is(error)) return decisionFor(error.code, error.retryAfterSeconds ?? null);
	return fallback();
}

// Lab harness, outside the shown regions: fakes for a duplicated copy of the module, where the
// classes have the same names but different constructors.
class DuplicateServiceUnavailableError extends Error {
	readonly retryAfterSeconds = 30;

	constructor() {
		super(messages['service-unavailable']);
		this.name = 'ServiceUnavailableError';
	}
}

class DuplicateNoteMissingError extends Error {
	readonly noteId = 42;

	constructor() {
		super(messages['note-missing']);
		this.name = 'NoteMissingError';
	}
}

class DuplicateAccessDeniedError extends Error {
	readonly requiredRole = 'editor';

	constructor() {
		super(messages['access-denied']);
		this.name = 'AccessDeniedError';
	}
}

function hierarchyFailure(code: FailureCode, environment: Environment): unknown {
	if (environment === 'duplicate-module') {
		switch (code) {
			case 'note-missing':
				return new DuplicateNoteMissingError();
			case 'service-unavailable':
				return new DuplicateServiceUnavailableError();
			case 'access-denied':
				return new DuplicateAccessDeniedError();
		}
	}

	switch (code) {
		case 'note-missing':
			return new NoteMissingError(42);
		case 'service-unavailable':
			return new ServiceUnavailableError(30);
		case 'access-denied':
			return new AccessDeniedError('editor');
	}
}
class DuplicateAppError extends Error {
	readonly name = 'AppError';
	readonly code: FailureCode;
	readonly retryAfterSeconds?: number;

	constructor(code: FailureCode) {
		super(messages[code]);
		this.code = code;
		if (code === 'service-unavailable') this.retryAfterSeconds = 30;
	}
}

function stableFailure(code: FailureCode, environment: Environment): unknown {
	const data: AppErrorData =
		code === 'note-missing'
			? { code, noteId: 42 }
			: code === 'service-unavailable'
				? { code, retryAfterSeconds: 30 }
				: { code, requiredRole: 'editor' };
	if (environment === 'duplicate-module') return new DuplicateAppError(code);
	const error = new AppError(data);
	return environment === 'json' ? JSON.parse(JSON.stringify(error)) : error;
}

function makeFailure(representation: Representation, code: FailureCode, environment: Environment) {
	if (representation === 'hierarchy') {
		const failure = hierarchyFailure(code, environment);
		if (environment === 'json') return JSON.parse(JSON.stringify(failure));
		return new Error('load note failed', { cause: failure });
	}
	const failure = stableFailure(code, environment);
	if (environment === 'json') return failure;
	return new Error('load note failed', { cause: failure });
}

export function observe(
	representation: Representation,
	code: FailureCode,
	environment: Environment
): Observation {
	const error = makeFailure(representation, code, environment);
	const decision = representation === 'hierarchy' ? byHierarchy(error) : byCode(error);
	const recognized = decision.action !== 'use generic fallback';
	return {
		representation,
		code,
		environment,
		recognized,
		path: recognized ? 'branch' : 'fallback',
		decision,
		detail: recognized
			? environment === 'same-module'
				? 'The caller matched its local failure contract.'
				: representation === 'stable-code'
					? 'The code survives this boundary, so the caller can keep its recovery branch.'
					: 'The class identity does not survive this boundary, so the typed branch is skipped.'
			: 'The caller kept the generic fallback because this representation no longer matches.'
	};
}

export function runExample() {
	return (['same-module', 'duplicate-module', 'json'] as Environment[]).map((environment) => ({
		environment,
		hierarchy: observe('hierarchy', 'service-unavailable', environment),
		stableCode: observe('stable-code', 'service-unavailable', environment)
	}));
}

console.log(JSON.stringify(runExample(), null, 2));

Save it as errors.ts and run node errors.ts (Node 22.18 or later runs TypeScript directly). It prints what each handler recognizes at the three boundaries the lab below compares.

Why keep cause separate from the display message?Context without losing evidence

A wrapper can say load note failed while its cause remains the typed service error. A classifier that walks the chain can still recover the branch. If the wrapper drops its cause, neither instanceof nor a code guard can recover what is gone.

Follow the error

Same failure. Different identity boundary.

The lab runs the classifiers from the source above. Keep “service unavailable” selected, then move from the same module to a duplicate package copy and finally to a serialized response. Predict which handler can still offer a retry and why.

A controlled comparison

Keep the failure fixed. Move the boundary.

Runs the TypeScript classifiers in your browser
ProducerService is unavailable
BoundarySame module copy
Caller needsChoose recovery
Subclass + instanceofbranch

offer retry

Retry hint: 30s

The caller matched its local failure contract.

Stable code + guardbranch

offer retry

Retry hint: 30s

The caller matched its local failure contract.

The hierarchy is a useful local contract. The stable code is the stronger choice once the value can be duplicated or serialized.

A duplicate package can produce an object with the same class name while its constructor is a different function. JSON is more decisive: it keeps fields, not prototypes. The stable-code twin works because its guard defines a data contract instead of asking the receiving runtime to share a constructor.

Make the boundary explicit

Choose the smallest identity contract that can survive.

There is no prize for the most elaborate hierarchy. Choose the mechanism that preserves the information the caller needs at the boundary it actually has.

A single TypeScript package owns both producer and caller.

The caller wants subclasses with different fields, and no value crosses a process boundary. Which starting point fits?

A backend sends failures to an independently deployed frontend.

The browser receives JSON and the two sides may release separately. What should the frontend recognize?

A package is bundled twice by different parts of an application.

The error names are the same, but each copy has its own constructor. Which guard avoids a silent instanceof miss?

Feedback stays on this page; it is not saved.
A production boundary

Let the owning boundary translate once.

Inside a TypeScript package, the repository can throw typed errors and the application boundary can choose a response. The boundary is where a private class becomes a public code and validated data, or where an unknown failure becomes a generic response with its original cause recorded.

Do not make a React component or a Svelte page recognize a database driver’s class. Translate at the request boundary, then let the view receive a local state it can render.

Producer

Throw evidence

Keep the original cause and the data its package owns.

Boundary

Translate once

Map known cases to a stable public contract and preserve unknown failures.

Caller

Choose a branch

Render empty, retry, access, or generic fallback without parsing text.

errors.ts · stable code twin
type AppErrorData = Readonly<{
	code: FailureCode;
	noteId?: number;
	retryAfterSeconds?: number;
	requiredRole?: string;
}>;

export class AppError extends Error {
	readonly code: FailureCode;
	readonly noteId?: number;
	readonly retryAfterSeconds?: number;
	readonly requiredRole?: string;

	constructor(data: AppErrorData, options?: ErrorOptions) {
		super(messages[data.code], options);
		this.name = 'AppError';
		this.code = data.code;
		this.noteId = data.noteId;
		this.retryAfterSeconds = data.retryAfterSeconds;
		this.requiredRole = data.requiredRole;
		Object.setPrototypeOf(this, new.target.prototype);
	}

	/** Structural recognition: validate every field a branch will trust, not just the name. */
	static is(value: unknown): value is AppError {
		if (typeof value !== 'object' || value === null) return false;
		const candidate = value as Record<string, unknown>;
		const optional = (field: string, type: 'number' | 'string') =>
			candidate[field] === undefined || typeof candidate[field] === type;
		return (
			candidate.name === 'AppError' &&
			(candidate.code === 'note-missing' ||
				candidate.code === 'service-unavailable' ||
				candidate.code === 'access-denied') &&
			optional('noteId', 'number') &&
			optional('retryAfterSeconds', 'number') &&
			optional('requiredRole', 'string')
		);
	}

	toJSON() {
		return {
			name: this.name,
			code: this.code,
			message: this.message,
			noteId: this.noteId,
			retryAfterSeconds: this.retryAfterSeconds,
			requiredRole: this.requiredRole
		};
	}
}

export function byCode(error: unknown): Decision {
	for (const cause of causes(error)) {
		if (!AppError.is(cause)) continue;
		return decisionFor(cause.code, cause.retryAfterSeconds ?? null);
	}
	if (AppError.is(error)) return decisionFor(error.code, error.retryAfterSeconds ?? null);
	return fallback();
}
Build UIs?An error boundary is a consumer of the contract, not a replacement for one.

Where it already is in your components

React error boundaries and Svelte route or page error states already separate an expected view state from an unexpected render failure. A data loader can turn a known response into “missing” or “retry” before the component renders it.

When you have to own it

When a UI must distinguish a stale session from a missing record, decode the service’s stable code at the request boundary. When a component sees an unknown error, hand it to the framework error boundary or a generic state; do not add a string match to every button handler.

Recognize it elsewhere

Frameworks and runtimes already make this tradeoff.

Error.cause

JavaScript’s cause option gives a wrapper a place to retain the original failure. It does not decide how callers classify that cause.

Read the MDN reference ↗

instanceof

The operator checks the prototype chain against a constructor in the current runtime. That is useful locally and fragile as an identity contract across copies and realms.

Read the operator reference ↗

HTTP error payloads

A public error response is already a serialized code-and-data contract. Treat it as a new local value after validation rather than as a transported class.

Compare kinds and sentinels ↗
The parts to watch

A hierarchy buys clarity at a specific boundary.

constructor.name is not a stable public code

Minifiers can rename a class. Set name deliberately for logs, but do not use the constructor name as an API code. A stable string code needs documented meaning and unknown-case behavior.

instanceof is about identity, not shape

Two constructors can create objects with the same fields and name while instanceof still returns false. A static guard can choose structural recognition, but it must validate the fields it trusts and avoid accepting arbitrary objects accidentally.

Inheritance does not enforce payload rules

A ServiceUnavailableError should always have a usable delay if callers depend on it. Make the constructor require that data, as the starting classes do, or use per-case shapes. A base class alone does not make a field meaningful.

Cause chains need a policy

Walking causes can find a useful domain failure. It also raises questions about cycles, conflicting causes, unknown wrappers, and what information a public boundary is allowed to expose. Define the policy where the chain is owned; do not recursively inspect every error forever.

A generic catch still has a job

A final fallback can keep an unknown failure from leaking private details. It should preserve telemetry and evidence, not turn a defect into a known business case or silently continue with bad state.

Make the call

Let the boundary decide whether identity is local.

Use a hierarchy when one TypeScript package owns the producer and caller, subclasses carry useful per-case data, and constructor identity is a deliberate local contract. Use an explicit code and validated payload when the value can be duplicated, cross a realm, or cross JSON. Keep the cause in either design when context would otherwise erase the evidence.

One package, local catch

Start with subclasses.

Readable branches and case-specific fields are worth the small hierarchy.

Duplicate bundles or realms

Prefer a stable guard.

Constructor identity is no longer a reliable shared contract.

Service and frontend

Define a public code.

Decode the wire response into a local case; never expect a class to travel.

Take the idea with you

A useful hierarchy is a boundary you can name.

If you remember one thing, remember the question beneath the syntax: who must recognize this failure, and what identity can that boundary actually share? A class can be the clearest answer inside one package. It is not a portable wire format.

Why
Name the recovery branches.
What
Use the smallest shared identity contract.
Constraint
One package owns both the thrower and the catch.
Fallback
Unknown or foreign errors take the generic path.
Reconsider when
The boundary or deployment shape changes.
Connections to follow nextRelated lessons
Explore more concepts & practices →