“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
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.
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.
Guard and narrow.
Explicit propagation. Every dependent result gets an early-return check.
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);
} Compose the cases.
Same success and failure values; propagation is named by small 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
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.
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.
Guard each result
Stopped early
The publish case stops the chain before later operations run.
Compose the results
Stopped early
The publish case stops the chain before later operations run.
Read the call siteTypeScript · early returns beside combinators
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.
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?”
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.
Return a case
Keep success data and expected failure data together in one typed value.
Translate once
Map local cases to a public response and preserve unknown failures as unknown.
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.
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.
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.
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.
Return a closed union.
Give each failure a discriminant and the data its caller needs.
Guard or compose.
Choose the form that keeps the next operation and its failure visible.
Reconsider the seam.
Combinators may help, but four forwarded failures can mean the operation boundary is misplaced.
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
- How a function reports failure is the choice before this one: which channel carries the failure at all.
- Class-based error hierarchies fit when the failure is better recognized as a local thrown type.
- Result types and combinators asks how far the
Resultmachinery should spread through a codebase. - Discriminated unions use the same tagged shape for state instead of outcomes.