← Concepts & practices
Pattern Errors, results, and recovery

Discriminated union results

Put failure in the return type.

A note save can succeed, reject its title, lose a version race, or meet a temporary outage. A plain value plus a vague error makes the caller rediscover those cases. A discriminated result puts the cases in the type, then asks an honest question: how many dependent results can one call chain carry before the guard clauses become the problem?

The judgment to keep

Use a closed result union when expected cases belong in the caller’s normal path. Use combinators to keep a short chain legible, and reconsider the boundary when every frame is only propagating failure.

TypeScript One save workflow · one language
Start with the caller

“Failed” needs a next step.

A note editor saves a title and body. The title may be empty, another editor may have saved first, the notification service may be down, or everything may work. Those are not one generic Error: the caller needs to fix, reload, retry, or show the saved note.

A nullable return can tell you that no note arrived. A tuple can carry a value and an error, but it still relies on every caller to remember which half is meaningful. A tagged result makes the choice visible: { ok: true, value } or { ok: false, error }.

The useful part is not the word Result. It is the closed set of failure cases that a caller can handle deliberately.

Read the smallest useful resultTypeScript · success and expected failures
results.ts · tagged result
export function saveNote(request: SaveRequest): Result<Note, SaveFailure> {
	if (!request.title.trim()) {
		return err({ kind: 'invalid', field: 'title', message: 'A note needs a title.' });
	}
	if (request.expectedVersion !== 3) {
		return err({
			kind: 'conflict',
			expectedVersion: request.expectedVersion,
			actualVersion: 3,
			message: 'The note changed before this save arrived.'
		});
	}
	return ok({ id: 42, title: request.title.trim(), body: request.body, version: 4 });
}

The type parameter T is the success value; E is the failure union. Checking result.ok narrows the value, and checking error.kind narrows the failure’s own fields.

Give the cases a shape

Start with a discriminant. Then make every reader pay attention.

The canonical form is intentionally plain: one union, one switch, and one never assertion. Add a failure kind and the compiler points to the readers that no longer cover the contract.

The twin adds the machinery teams usually need after the first happy example. map changes a success value, andThen starts the next result-producing operation only when the previous one succeeded, and unwrapOr names the local fallback.

Canonical

Guard and narrow.

Explicit propagation. Every dependent result gets an early-return check.

results.ts
export function assertNever(value: never): never {
	throw new Error(`Unhandled result case: ${JSON.stringify(value)}`);
}

export function describeResult(result: Result<Note, SaveFailure>): string {
	if (result.ok) return `Saved “${result.value.title}” as version ${result.value.version}.`;

	switch (result.error.kind) {
		case 'invalid':
			return `Fix ${result.error.field}: ${result.error.message}`;
		case 'conflict':
			return `${result.error.message} Reload version ${result.error.actualVersion}.`;
		case 'unavailable':
			return `${result.error.message} Retry after ${result.error.retryAfterSeconds}s.`;
		default:
			return assertNever(result.error);
	}
}

function failureFor(step: StepName): SaveFailure {
	switch (step) {
		case 'validate':
			return { kind: 'invalid', field: 'title', message: 'A note needs a title.' };
		case 'persist':
			return {
				kind: 'conflict',
				expectedVersion: 3,
				actualVersion: 4,
				message: 'The note changed before persistence finished.'
			};
		case 'publish':
			return {
				kind: 'unavailable',
				retryAfterSeconds: 15,
				message: 'The notification service is temporarily unavailable.'
			};
		case 'index':
			return {
				kind: 'unavailable',
				retryAfterSeconds: 30,
				message: 'The search index is temporarily unavailable.'
			};
	}
}

function runStep(value: string, step: StepName, failureAt: FailureAt): Result<string, SaveFailure> {
	return failureAt === step ? err(failureFor(step)) : ok(`${value} → ${step}`);
}

export function runWithGuards(
	depth: ChainDepth,
	failureAt: FailureAt
): Result<string, SaveFailure> {
	let value = 'draft';
	if (depth >= 1) {
		const result = runStep(value, 'validate', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	if (depth >= 2) {
		const result = runStep(value, 'persist', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	if (depth >= 3) {
		const result = runStep(value, 'publish', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	if (depth >= 4) {
		const result = runStep(value, 'index', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	return ok(value);
}
Twin

Compose the cases.

Same success and failure values; propagation is named by small combinators.

results.ts · combinators
export function map<T, U, E>(result: Result<T, E>, transform: (value: T) => U): Result<U, E> {
	return result.ok ? ok(transform(result.value)) : { ok: false, error: result.error };
}

export function andThen<T, U, E>(
	result: Result<T, E>,
	next: (value: T) => Result<U, E>
): Result<U, E> {
	return result.ok ? next(result.value) : { ok: false, error: result.error };
}

export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
	return result.ok ? result.value : fallback;
}

export function runWithCombinators(
	depth: ChainDepth,
	failureAt: FailureAt
): Result<string, SaveFailure> {
	let result: Result<string, SaveFailure> = ok('draft');
	for (const step of stepOrder.slice(0, depth)) {
		result = andThen(result, (value) => runStep(value, step, failureAt));
	}
	return result;
}
See the complete programCopyable source plus invocation
results.ts
export type SaveFailure =
	| { kind: 'invalid'; field: 'title'; message: string }
	| { kind: 'conflict'; expectedVersion: number; actualVersion: number; message: string }
	| { kind: 'unavailable'; retryAfterSeconds: number; message: string };

export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

export type SaveRequest = Readonly<{
	title: string;
	body: string;
	expectedVersion: number;
}>;

export type Note = Readonly<{
	id: number;
	title: string;
	body: string;
	version: number;
}>;

export type StepName = 'validate' | 'persist' | 'publish' | 'index';
export type ChainDepth = 1 | 2 | 3 | 4;
export type FailureAt = 'none' | StepName;
export type Representation = 'guards' | 'combinators';

export type Observation = Readonly<{
	representation: Representation;
	depth: ChainDepth;
	failureAt: FailureAt;
	result: 'success' | 'failure';
	completedSteps: number;
	manualGuards: number;
	finalBranches: number;
	output: string;
	detail: string;
}>;

const stepOrder: readonly StepName[] = ['validate', 'persist', 'publish', 'index'];

function ok<T>(value: T): Result<T, never> {
	return { ok: true, value };
}

function err<E>(error: E): Result<never, E> {
	return { ok: false, error };
}

export function saveNote(request: SaveRequest): Result<Note, SaveFailure> {
	if (!request.title.trim()) {
		return err({ kind: 'invalid', field: 'title', message: 'A note needs a title.' });
	}
	if (request.expectedVersion !== 3) {
		return err({
			kind: 'conflict',
			expectedVersion: request.expectedVersion,
			actualVersion: 3,
			message: 'The note changed before this save arrived.'
		});
	}
	return ok({ id: 42, title: request.title.trim(), body: request.body, version: 4 });
}

export function assertNever(value: never): never {
	throw new Error(`Unhandled result case: ${JSON.stringify(value)}`);
}

export function describeResult(result: Result<Note, SaveFailure>): string {
	if (result.ok) return `Saved “${result.value.title}” as version ${result.value.version}.`;

	switch (result.error.kind) {
		case 'invalid':
			return `Fix ${result.error.field}: ${result.error.message}`;
		case 'conflict':
			return `${result.error.message} Reload version ${result.error.actualVersion}.`;
		case 'unavailable':
			return `${result.error.message} Retry after ${result.error.retryAfterSeconds}s.`;
		default:
			return assertNever(result.error);
	}
}

function failureFor(step: StepName): SaveFailure {
	switch (step) {
		case 'validate':
			return { kind: 'invalid', field: 'title', message: 'A note needs a title.' };
		case 'persist':
			return {
				kind: 'conflict',
				expectedVersion: 3,
				actualVersion: 4,
				message: 'The note changed before persistence finished.'
			};
		case 'publish':
			return {
				kind: 'unavailable',
				retryAfterSeconds: 15,
				message: 'The notification service is temporarily unavailable.'
			};
		case 'index':
			return {
				kind: 'unavailable',
				retryAfterSeconds: 30,
				message: 'The search index is temporarily unavailable.'
			};
	}
}

function runStep(value: string, step: StepName, failureAt: FailureAt): Result<string, SaveFailure> {
	return failureAt === step ? err(failureFor(step)) : ok(`${value} → ${step}`);
}

export function runWithGuards(
	depth: ChainDepth,
	failureAt: FailureAt
): Result<string, SaveFailure> {
	let value = 'draft';
	if (depth >= 1) {
		const result = runStep(value, 'validate', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	if (depth >= 2) {
		const result = runStep(value, 'persist', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	if (depth >= 3) {
		const result = runStep(value, 'publish', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	if (depth >= 4) {
		const result = runStep(value, 'index', failureAt);
		if (!result.ok) return result;
		value = result.value;
	}
	return ok(value);
}

export function map<T, U, E>(result: Result<T, E>, transform: (value: T) => U): Result<U, E> {
	return result.ok ? ok(transform(result.value)) : { ok: false, error: result.error };
}

export function andThen<T, U, E>(
	result: Result<T, E>,
	next: (value: T) => Result<U, E>
): Result<U, E> {
	return result.ok ? next(result.value) : { ok: false, error: result.error };
}

export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
	return result.ok ? result.value : fallback;
}

export function runWithCombinators(
	depth: ChainDepth,
	failureAt: FailureAt
): Result<string, SaveFailure> {
	let result: Result<string, SaveFailure> = ok('draft');
	for (const step of stepOrder.slice(0, depth)) {
		result = andThen(result, (value) => runStep(value, step, failureAt));
	}
	return result;
}

export function observe(
	representation: Representation,
	depth: ChainDepth,
	failureAt: FailureAt
): Observation {
	const result =
		representation === 'guards'
			? runWithGuards(depth, failureAt)
			: runWithCombinators(depth, failureAt);
	const failureIndex = failureAt === 'none' ? -1 : stepOrder.indexOf(failureAt);
	const reachedFailure = failureIndex >= 0 && failureIndex < depth;
	const completedSteps = result.ok ? depth : Math.max(0, failureIndex);
	const output = result.ok
		? unwrapOr(
				map(result, (value) => `Complete: ${value}`),
				''
			)
		: 'Stopped early';

	return {
		representation,
		depth,
		failureAt,
		result: result.ok ? 'success' : 'failure',
		completedSteps: reachedFailure ? completedSteps : depth,
		manualGuards: depth,
		finalBranches: 1,
		output,
		detail: result.ok
			? failureAt === 'none'
				? `${depth} operation${depth === 1 ? '' : 's'} completed; the caller handles one final Result.`
				: `The ${failureAt} failure is outside this ${depth}-step path, so the Result completes.`
			: `The ${failureAt} case stops the chain before later operations run.`
	};
}

export function runExample() {
	return {
		boundary: describeResult(
			saveNote({ title: 'Trip notes', body: 'Pack a charger.', expectedVersion: 3 })
		),
		invalid: describeResult(saveNote({ title: ' ', body: 'No title.', expectedVersion: 3 })),
		guards: observe('guards', 4, 'publish'),
		combinators: observe('combinators', 4, 'publish')
	};
}

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

Save it as results.ts and run node results.ts (Node 22.18 or later runs TypeScript directly). It prints the saved note, the title correction, and a publish failure followed through both representations.

What exhaustiveness does, and does not, buyA compile-time reader check

The kind field lets TypeScript narrow the failure to one case. The assertNever call turns a missing branch into a compile-time error when the union is closed in this module. That is a reader guarantee, not runtime validation of data that arrived from JSON.

The pattern also does not enforce domain arithmetic. A result can carry a number that is technically typed but wrong for the business rule; validate those invariants where the value enters the domain.

Follow the result

One extra step is harmless. Four deserve a conversation.

The lab runs the same TypeScript operations in two representations. Choose how deep the path is and inject a failure. Both versions preserve the failure and stop later work; the difference is how much propagation ceremony the caller must read.

Validate draftPersist notePublish eventUpdate search index
Canonical

Guard each result

stopped

Stopped early

4 manual guards
1final branch
2steps completed

The publish case stops the chain before later operations run.

Twin

Compose the results

stopped

Stopped early

4 andThen calls
1final branch
2steps completed

The publish case stops the chain before later operations run.

The controls change a local simulation; nothing is saved.
Read the call siteTypeScript · early returns beside combinators
results.ts
export function observe(
	representation: Representation,
	depth: ChainDepth,
	failureAt: FailureAt
): Observation {
	const result =
		representation === 'guards'
			? runWithGuards(depth, failureAt)
			: runWithCombinators(depth, failureAt);
	const failureIndex = failureAt === 'none' ? -1 : stepOrder.indexOf(failureAt);
	const reachedFailure = failureIndex >= 0 && failureIndex < depth;
	const completedSteps = result.ok ? depth : Math.max(0, failureIndex);
	const output = result.ok
		? unwrapOr(
				map(result, (value) => `Complete: ${value}`),
				''
			)
		: 'Stopped early';

	return {
		representation,
		depth,
		failureAt,
		result: result.ok ? 'success' : 'failure',
		completedSteps: reachedFailure ? completedSteps : depth,
		manualGuards: depth,
		finalBranches: 1,
		output,
		detail: result.ok
			? failureAt === 'none'
				? `${depth} operation${depth === 1 ? '' : 's'} completed; the caller handles one final Result.`
				: `The ${failureAt} failure is outside this ${depth}-step path, so the Result completes.`
			: `The ${failureAt} case stops the chain before later operations run.`
	};
}

export function runExample() {
	return {
		boundary: describeResult(
			saveNote({ title: 'Trip notes', body: 'Pack a charger.', expectedVersion: 3 })
		),
		invalid: describeResult(saveNote({ title: ' ', body: 'No title.', expectedVersion: 3 })),
		guards: observe('guards', 4, 'publish'),
		combinators: observe('combinators', 4, 'publish')
	};
}

An andThen chain does not make the operations free and it does not make a deep workflow automatically clearer. It moves the repeated “if failed, return” rule into a named abstraction. That is useful until the abstraction hides an important business decision.

Make the boundary explicit

Choose the contract the caller can actually use.

The right question is not “Can I write this as a union?” It is “Are these finite outcomes expected, and can this caller act on each one?”

A note save can succeed, reject the title, or hit a version conflict.

The caller must show a different next step for each case. Which signature keeps those cases visible?

You add “offline” to the failure union.

What should the compiler force you to revisit?

Four dependent operations each return Result.

What is the honest response when one guard clause has become four?

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

Return a local case; translate once at the edge.

A service handler can turn Result<Note, SaveFailure> into a response: invalid input becomes a client correction, a version conflict becomes a reload prompt, and an outage becomes a bounded retry. The UI receives a contract shaped for rendering instead of a database error it has to recognize.

Keep the union narrow at the boundary. Do not expose driver details just because the inner function returned them, and do not map an unknown defect to “try again” without an ownership decision and telemetry.

Producer

Return a case

Keep success data and expected failure data together in one typed value.

Boundary

Translate once

Map local cases to a public response and preserve unknown failures as unknown.

Caller

Choose a view

Render saved, fix, reload, or retry without parsing messages.

Build UIs?A result union is a state contract for the component that consumes it.

Where it already is in your components

Loading, error, and success states are often written as three flags. A discriminated state keeps impossible combinations out: a success case carries data, while an error case carries its reason. The existing Discriminated unions lesson follows that shape through an import state.

When you have to own it

When a form needs distinct recovery actions, define the cases before wiring the buttons. When a component receives an unvalidated response, decode it at the request boundary first; a TypeScript annotation alone does not check JSON.

Recognize it elsewhere

The same contract wears different syntax.

Promise<T>

A promise already has two runtime outcomes: fulfillment and rejection. A typed result is useful when expected failure cases should stay in the ordinary return path and be exhaustively read before the async boundary.

HTTP response bodies

A response with a status or code is a serialized discriminant. Validate it at the edge, then turn it into a local union instead of treating every body as success-shaped data.

Promise.allSettled outcomes

Each settled entry is already a result union: status: 'fulfilled' carries a value, status: 'rejected' carries a reason. You narrow on status exactly as you narrow on ok.

The parts to watch

Explicit failure is not free failure.

Every new case has a reader cost

That cost is the point at a boundary: a new expected outcome should find its callers. Keep the union’s scope intentional so an unrelated internal detail does not force every consumer to learn a new branch.

Do not use undefined as a hidden union

If callers need to distinguish missing, invalid, and unavailable, a nullable value throws away the information they need. If absence is the complete answer, nullable may be the more honest type.

Combinators can hide decisions

A compact chain is not automatically readable. If each step needs a different compensation, metric, or permission decision, write those decisions where the reader can see them instead of pushing everything through a generic andThen.

Exhaustiveness is not input validation

A value typed as Result<Note, SaveFailure> can still be untrusted if it came from a wire. Parse and validate at that boundary, then let the local union make its readers complete.

Make the call

Use the union where the caller has a decision to make.

Use a discriminated result when expected outcomes are finite, each case has useful data, and the caller should not forget to handle one. Keep the basic union and exhaustive reader close to the boundary. Add map, andThen, or unwrapOr when they remove repeated plumbing without hiding the business choice.

Finite expected outcomes

Return a closed union.

Give each failure a discriminant and the data its caller needs.

Short dependent chain

Guard or compose.

Choose the form that keeps the next operation and its failure visible.

Deep propagation-only chain

Reconsider the seam.

Combinators may help, but four forwarded failures can mean the operation boundary is misplaced.

Take the idea with you

Make “what can happen next?” part of the signature.

A result union is a small promise: these are the outcomes this operation expects you to consider. Its value comes from the reader that handles the cases, not from wrapping every line in { ok, error }.

Why
Give the caller finite, actionable cases.
What
Use a discriminant and an exhaustive reader.
Constraint
The expected outcomes are finite and each has data the caller uses.
Fallback
Defects outside the union still throw.
Reconsider when
Propagation obscures the operation or the failure is exceptional.
Connections to follow nextRelated lessons
Explore more concepts & practices →