← Concepts & practices
Choice Errors, results, and recovery

Expected vs. unrecoverable

Which failures belong in the normal path?

A seat being taken is a result the customer can respond to. A ledger saying that one seat is held twice is a defect. Follow both through the same reservation operation and decide what the caller should receive, what should escape, and where the blast radius belongs.

TypeScriptGo Severity is a contract decision.

01 / The decision

“Failed” does not mean “same next step.”

A reservation request asks for seat 12A. The seat may be available, may have just been taken, or may not belong to this show. Those are expected outcomes: the caller can offer another seat or ask for a correction.

Now imagine the ledger reports that the same seat is held twice. No customer choice repairs that state. It is an invariant violation: a defect in the system’s assumptions, not a fourth business answer.

Read the familiar starting callTypeScript and Go · one operation, one fault input
TypeScriptstarting contract
severity.ts · starting contract
/** One exception-shaped channel for both expected outcomes and defects. */
export function submitAllThrow(fault: Fault): Receipt {
	const expected = expectedFailureFor(fault);
	if (expected) throw new ReservationFailure(expected);
	if (fault === 'invariant') throw new InvariantViolation();
	return receipt();
}
Gostarting contract
severity.go · starting contract
// SubmitAllError is the closest Go equivalent to putting every failure in one
// channel: expected failures and defects both arrive as an error value.
func SubmitAllError(fault Fault) (Receipt, error) {
	switch fault {
	case Valid:
		return Receipt{ReservationID: "R-204", Seat: "12A"}, nil
	case SoldOut:
		return Receipt{}, ExpectedFailure{Kind: FailureSoldOut, Seat: "12A", Message: "Seat 12A has just been taken."}
	case Invalid:
		return Receipt{}, ExpectedFailure{Kind: FailureInvalid, Field: "seat", Message: "Choose a seat from this show."}
	case Invariant:
		return Receipt{}, InvariantViolation{Message: "The reservation ledger has an impossible state."}
	default:
		return Receipt{}, errors.New("unknown reservation fault")
	}
}

The previous lesson, How a function reports failure, compared throw, nullable, tuple, and tagged-result channels. This lesson asks a different question: should this outcome be in the caller’s normal contract at all?

02 / The alternatives

Three policies, three places for the defect.

Each policy can be made to work mechanically. Compare what it promises when the caller receives a sold-out seat and when the ledger is impossible.

A

Everything throws

One catch receives both classes.

Expected failure and invariant violation leave the return path through the same control-flow channel.

Familiar boundary; a broad catch can turn a defect into an ordinary response.
B

Everything returns

One result carries every state.

Both a customer-correctable outcome and a broken invariant become values the caller must inspect.

Explicit data; a caller can normalize a bug, persist it, or show it as user feedback.
C

Split by severity

Expected returns; defects escape.

The normal result lists actions the caller can take. An invariant violation reaches its owning boundary.

Recommended here; the boundary still needs an explicit containment and alerting policy.
What the policy tells the caller
QuestionEverything throwsEverything returnsSplit by severity
Sold-out seat?Catch and classify.Inspect an expected case.Return an expected case.
Impossible ledger?Catch path can hide it.Ordinary data can hide it.Escape to the owning boundary.
Who chooses blast radius?The nearest broad catch.Every caller, unless disciplined.The boundary with operational context.
Main costInvisible control flow.Defects look recoverable.Two deliberate paths to document.

“Throw” and “return” are failure channels; “expected” and “unrecoverable” are severity classes. A typed exception can still represent an expected failure, and a returned result can still contain a defect. The useful design move is to make the mismatch difficult to mistake.

03 / Change a constraint

Change the outcome, keep the operation fixed.

Choose a seat outcome below. For an expected failure, the caller needs an ordinary action. For an invariant violation, the caller needs a boundary that can stop, alert, and decide what data or work is safe to keep.

A controlled comparison

Keep the reservation job fixed. Change the severity.

Runs the TypeScript model in your browser
Caller asksReserve seat 12A
Operation discoversThe ledger has an impossible state
Boundary muststop and alert
Everything throwscaught-defect

return generic error

One catch path receives both customer outcomes and invariant violations.

The catch boundary caught an invariant violation beside expected failures; a broad catch can hide a bug as an ordinary server error.

Everything returnsdefect

return generic error

One result shape carries both expected failures and defects as data.

The invariant violation is now ordinary result data. A caller can accidentally normalize a defect as a business outcome.

Expected returns; defects escapedefect

stop and alert

The normal result lists caller-recoverable outcomes; the boundary owns defects.

The invariant violation escaped the normal result. A boundary can stop, alert, and choose its blast radius.

The split policy is the recommendation for this scenario. It does not prescribe whether the boundary restarts a worker or how the incident is logged; it keeps that decision visible.

Notice the distinction in the cards: the split policy does not claim that “panic” or “throw” magically repairs the ledger. It gives the defect to code that knows the deployment’s recovery boundary instead of asking a seat-picker to invent a customer action.

04 / Compare the source

Let the normal signature stop at the normal boundary.

TypeScript can make the expected union visible and throw an InvariantViolation outside it. Go has no expected-failure exception mechanism: its ordinary path is (value, error), while panic marks the defect branch in this example.

TypeScriptsplit policy
severity.ts · split policy
/** Expected outcomes stay in the normal result; a defect escapes to its boundary. */
export function submitSplit(fault: Fault): Result<Receipt, ExpectedFailure> {
	const expected = expectedFailureFor(fault);
	if (expected) return { ok: false, error: expected };
	if (fault === 'invariant') throw new InvariantViolation();
	return { ok: true, value: receipt() };
}
Gosplit policy
severity.go · split policy
// SubmitSplit returns expected failures through Go's normal error protocol.
// An impossible ledger state panics so the owning boundary cannot mistake it
// for a seat the user can choose differently.
func SubmitSplit(fault Fault) (Receipt, error) {
	if fault == Invariant {
		panic(InvariantViolation{Message: "The reservation ledger has an impossible state."})
	}
	return SubmitAllError(fault)
}
Read the TypeScriptExpected result plus invariant exception

Result<Receipt, ExpectedFailure> lists only sold-out and invalid. The invariant branch throws InvariantViolation, so a caller cannot handle it as if the customer had picked a different seat. The lab catches it only to make the observation safe.

Read the GoError return plus panic boundary

Go callers inspect an error for expected outcomes. SubmitSplit keeps that protocol for sold-out and invalid, but panics on the impossible state. A real service must decide where, if anywhere, recovery is allowed; recover is not a substitute for classification.

See the complete programsCopyable source plus invocation
TypeScriptcomplete file
severity.ts
export type Fault = 'valid' | 'sold-out' | 'invalid' | 'invariant';
export type Policy = 'all-throw' | 'all-return' | 'split';

export type Receipt = Readonly<{
	reservationId: string;
	seat: string;
}>;

export type ExpectedFailure =
	| Readonly<{ kind: 'sold-out'; seat: string; message: string }>
	| Readonly<{ kind: 'invalid'; field: 'seat'; message: string }>;

export type Defect = Readonly<{
	kind: 'invariant';
	message: string;
}>;

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

export type Observation = Readonly<{
	policy: Policy;
	fault: Fault;
	path: 'success' | 'expected' | 'defect' | 'caught-defect';
	action:
		| 'confirm reservation'
		| 'ask for another seat'
		| 'show validation'
		| 'stop and alert'
		| 'return generic error';
	detail: string;
}>;

function receipt(): Receipt {
	return { reservationId: 'R-204', seat: '12A' };
}

function expectedFailureFor(fault: Fault): ExpectedFailure | null {
	switch (fault) {
		case 'sold-out':
			return { kind: 'sold-out', seat: '12A', message: 'Seat 12A has just been taken.' };
		case 'invalid':
			return { kind: 'invalid', field: 'seat', message: 'Choose a seat from this show.' };
		case 'valid':
		case 'invariant':
			return null;
		default:
			throw new Error('unknown reservation fault');
	}
}

export class ReservationFailure extends Error {
	readonly failure: ExpectedFailure;

	constructor(failure: ExpectedFailure) {
		super(failure.message);
		this.name = 'ReservationFailure';
		this.failure = failure;
	}
}

export class InvariantViolation extends Error {
	constructor(message = 'The reservation ledger has an impossible state.') {
		super(message);
		this.name = 'InvariantViolation';
	}
}

/** One exception-shaped channel for both expected outcomes and defects. */
export function submitAllThrow(fault: Fault): Receipt {
	const expected = expectedFailureFor(fault);
	if (expected) throw new ReservationFailure(expected);
	if (fault === 'invariant') throw new InvariantViolation();
	return receipt();
}

export function submitAllReturn(fault: Fault): Result<Receipt, Reported> {
	const expected = expectedFailureFor(fault);
	if (expected) return { ok: false, error: expected };
	if (fault === 'invariant') {
		return {
			ok: false,
			error: { kind: 'invariant', message: 'The reservation ledger has an impossible state.' }
		};
	}
	return { ok: true, value: receipt() };
}

/** Expected outcomes stay in the normal result; a defect escapes to its boundary. */
export function submitSplit(fault: Fault): Result<Receipt, ExpectedFailure> {
	const expected = expectedFailureFor(fault);
	if (expected) return { ok: false, error: expected };
	if (fault === 'invariant') throw new InvariantViolation();
	return { ok: true, value: receipt() };
}

function expectedAction(failure: ExpectedFailure): Observation['action'] {
	return failure.kind === 'sold-out' ? 'ask for another seat' : 'show validation';
}

function expectedDetail(failure: ExpectedFailure): string {
	return failure.kind === 'sold-out'
		? `${failure.message} The caller can offer another seat.`
		: `${failure.message} The caller can correct the request.`;
}

function successObservation(policy: Policy, fault: Fault, value: Receipt): Observation {
	return {
		policy,
		fault,
		path: 'success',
		action: 'confirm reservation',
		detail: `${value.seat} is reserved as ${value.reservationId}.`
	};
}

export function observe(policy: Policy, fault: Fault): Observation {
	if (policy === 'all-throw') {
		try {
			return successObservation(policy, fault, submitAllThrow(fault));
		} catch (error) {
			if (error instanceof ReservationFailure) {
				return {
					policy,
					fault,
					path: 'expected',
					action: expectedAction(error.failure),
					detail: `${expectedDetail(error.failure)} It arrived through the same throw path as defects.`
				};
			}
			return {
				policy,
				fault,
				path: 'caught-defect',
				action: 'return generic error',
				detail:
					'The catch boundary caught an invariant violation beside expected failures; a broad catch can hide a bug as an ordinary server error.'
			};
		}
	}

	if (policy === 'all-return') {
		const result = submitAllReturn(fault);
		if (result.ok) return successObservation(policy, fault, result.value);
		if (result.error.kind === 'invariant') {
			return {
				policy,
				fault,
				path: 'defect',
				action: 'return generic error',
				detail:
					'The invariant violation is now ordinary result data. A caller can accidentally normalize a defect as a business outcome.'
			};
		}
		return {
			policy,
			fault,
			path: 'expected',
			action: expectedAction(result.error),
			detail: expectedDetail(result.error)
		};
	}

	try {
		const result = submitSplit(fault);
		if (result.ok) return successObservation(policy, fault, result.value);
		return {
			policy,
			fault,
			path: 'expected',
			action: expectedAction(result.error),
			detail: `${expectedDetail(result.error)} It is listed in the normal caller contract.`
		};
	} catch (error) {
		return {
			policy,
			fault,
			path: 'defect',
			action: 'stop and alert',
			detail:
				error instanceof InvariantViolation
					? 'The invariant violation escaped the normal result. A boundary can stop, alert, and choose its blast radius.'
					: 'An unknown failure escaped the normal result and should be handled by the owning boundary.'
		};
	}
}

export type BoundaryResponse = Readonly<{ status: 200 | 409 | 422 | 500; body: string }>;

/** The request handler owns containment: expected failures become answers, defects an alert. */
export function handleReservation(fault: Fault, alert: (error: unknown) => void): BoundaryResponse {
	try {
		const result = submitSplit(fault);
		if (result.ok) {
			return {
				status: 200,
				body: `Reserved ${result.value.seat} as ${result.value.reservationId}.`
			};
		}
		return { status: result.error.kind === 'sold-out' ? 409 : 422, body: result.error.message };
	} catch (error) {
		// Only the boundary catches the defect: it keeps the evidence and fails this request.
		alert(error);
		return { status: 500, body: 'Reservations are paused while we check the ledger.' };
	}
}

export function runExample() {
	return (['valid', 'sold-out', 'invariant'] as Fault[]).map((fault) => ({
		fault,
		allThrow: observe('all-throw', fault),
		allReturn: observe('all-return', fault),
		split: observe('split', fault)
	}));
}

console.log(JSON.stringify(runExample(), null, 2));
Gocomplete file
severity.go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

type Fault string

const (
	Valid     Fault = "valid"
	SoldOut   Fault = "sold-out"
	Invalid   Fault = "invalid"
	Invariant Fault = "invariant"
)

type Receipt struct {
	ReservationID string `json:"reservationId"`
	Seat          string `json:"seat"`
}

type FailureKind string

const (
	FailureSoldOut   FailureKind = "sold-out"
	FailureInvalid   FailureKind = "invalid"
	FailureInvariant FailureKind = "invariant"
)

type ExpectedFailure struct {
	Kind    FailureKind `json:"kind"`
	Seat    string      `json:"seat,omitempty"`
	Field   string      `json:"field,omitempty"`
	Message string      `json:"message"`
}

func (f ExpectedFailure) Error() string { return f.Message }

type InvariantViolation struct{ Message string }

func (e InvariantViolation) Error() string { return e.Message }

type Reported struct {
	Expected  *ExpectedFailure
	Invariant *InvariantViolation
}

// SubmitAllError is the closest Go equivalent to putting every failure in one
// channel: expected failures and defects both arrive as an error value.
func SubmitAllError(fault Fault) (Receipt, error) {
	switch fault {
	case Valid:
		return Receipt{ReservationID: "R-204", Seat: "12A"}, nil
	case SoldOut:
		return Receipt{}, ExpectedFailure{Kind: FailureSoldOut, Seat: "12A", Message: "Seat 12A has just been taken."}
	case Invalid:
		return Receipt{}, ExpectedFailure{Kind: FailureInvalid, Field: "seat", Message: "Choose a seat from this show."}
	case Invariant:
		return Receipt{}, InvariantViolation{Message: "The reservation ledger has an impossible state."}
	default:
		return Receipt{}, errors.New("unknown reservation fault")
	}
}


type Result[T any, E any] struct {
	Value T
	Error *E
}

// SubmitAllReturn keeps even an invariant violation in ordinary result data.
func SubmitAllReturn(fault Fault) Result[Receipt, Reported] {
	value, err := SubmitAllError(fault)
	if err == nil {
		return Result[Receipt, Reported]{Value: value}
	}
	var expected ExpectedFailure
	if errors.As(err, &expected) {
		return Result[Receipt, Reported]{Error: &Reported{Expected: &expected}}
	}
	var invariant InvariantViolation
	if errors.As(err, &invariant) {
		return Result[Receipt, Reported]{Error: &Reported{Invariant: &invariant}}
	}
	return Result[Receipt, Reported]{Error: &Reported{Invariant: &InvariantViolation{Message: err.Error()}}}
}


// SubmitSplit returns expected failures through Go's normal error protocol.
// An impossible ledger state panics so the owning boundary cannot mistake it
// for a seat the user can choose differently.
func SubmitSplit(fault Fault) (Receipt, error) {
	if fault == Invariant {
		panic(InvariantViolation{Message: "The reservation ledger has an impossible state."})
	}
	return SubmitAllError(fault)
}


type Response struct {
	Status int    `json:"status"`
	Body   string `json:"body"`
}

// HandleReservation is the boundary that owns containment: expected failures
// become answers, and a defect is recovered here, alerted, and fails this request.
func HandleReservation(fault Fault, alert func(any)) (response Response) {
	defer func() {
		if recovered := recover(); recovered != nil {
			alert(recovered)
			response = Response{Status: 500, Body: "Reservations are paused while we check the ledger."}
		}
	}()
	receipt, err := SubmitSplit(fault)
	var expected ExpectedFailure
	switch {
	case err == nil:
		return Response{Status: 200, Body: fmt.Sprintf("Reserved %s as %s.", receipt.Seat, receipt.ReservationID)}
	case errors.As(err, &expected) && expected.Kind == FailureSoldOut:
		return Response{Status: 409, Body: expected.Message}
	case errors.As(err, &expected):
		return Response{Status: 422, Body: expected.Message}
	default:
		alert(err)
		return Response{Status: 500, Body: "Reservations are paused while we check the ledger."}
	}
}


func Example() map[string]any {
	_, expected := SubmitSplit(SoldOut)
	allReturn := SubmitAllReturn(Invariant)
	defectEscaped := recoverInvariant()
	return map[string]any{
		"expectedKind":     expected.(ExpectedFailure).Kind,
		"allReturnDefect":  allReturn.Error.Invariant != nil,
		"splitPanics":      defectEscaped,
		"validReservation": mustReserve(Valid).ReservationID,
		"boundaryStatus":   HandleReservation(Invariant, func(any) {}).Status,
	}
}

func recoverInvariant() (deferred bool) {
	defer func() {
		if recover() != nil {
			deferred = true
		}
	}()
	_, _ = SubmitSplit(Invariant)
	return false
}

func mustReserve(fault Fault) Receipt {
	receipt, err := SubmitSplit(fault)
	if err != nil {
		panic(err)
	}
	return receipt
}


func main() {
	encoded, err := json.MarshalIndent(Example(), "", "  ")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(encoded))
}

Save the TypeScript as severity.ts and run node severity.ts (Node 22.18 or later runs TypeScript directly). Save the Go as severity.go and run go run severity.go.

05 / Try a decision

Classify the failure before choosing the helper.

The return shape may vary by language or by whether errors accumulate. The severity question comes first: can the caller take a valid product action, or must an owning boundary investigate?

A seat was taken one second ago.

The customer can choose another seat. Which policy keeps that outcome actionable without making an impossible ledger state look normal?

A pure function checks that a typed seat code is well formed.

It reads no ledger and holds no state. Every way it can fail is a typo the customer can fix. Which policy fits this function?

The reservation ledger says one seat is held twice.

No customer action can repair this state. Which policy stops it from being silently normalized as another business result?

Feedback stays on this page; it is not saved.

06 / Give it a real job

A reservation boundary should know what it owns.

The reservation function owns the facts it can report. The caller owns customer actions for expected outcomes. A service boundary owns the operational decision when the ledger’s assumptions are broken.

Operation

Classify

Return sold-out or invalid when the customer can act; identify impossible state as a defect.

Caller

Recover normally

Offer another seat, show validation, or confirm the receipt.

Boundary

Contain

Stop, alert, and choose a safe blast radius for an invariant violation.

severity.ts · containing boundary
export type BoundaryResponse = Readonly<{ status: 200 | 409 | 422 | 500; body: string }>;

/** The request handler owns containment: expected failures become answers, defects an alert. */
export function handleReservation(fault: Fault, alert: (error: unknown) => void): BoundaryResponse {
	try {
		const result = submitSplit(fault);
		if (result.ok) {
			return {
				status: 200,
				body: `Reserved ${result.value.seat} as ${result.value.reservationId}.`
			};
		}
		return { status: result.error.kind === 'sold-out' ? 409 : 422, body: result.error.message };
	} catch (error) {
		// Only the boundary catches the defect: it keeps the evidence and fails this request.
		alert(error);
		return { status: 500, body: 'Reservations are paused while we check the ledger.' };
	}
}

Here the boundary is a request handler. It turns sold-out and invalid into ordinary answers, and it is the only code that catches the invariant violation: it hands the evidence to an alert hook and fails this one request. It might equally be a worker supervisor or a UI error boundary. The important part is not the framework name: it is that the code with operational context receives the defect instead of a seat-picker pretending it is user input.

Build UIs?The component should own customer recovery, not system diagnosis.

Where it already is in your components

A submit handler already has two UI states: it can render a corrected field or let a customer choose again. A React error boundary or Svelte route boundary already has another job: contain failures that are not part of the component’s expected result.

When you have to own it

When your reservation endpoint returns every failure as a generic rejection, decide at the boundary whether to translate known customer outcomes and rethrow or report unknown defects. Do not make the component infer severity from a message string.

A small reservation form catches one broad rejection, making sold-out and invariant failures share a UI boundary.

ReactAlready in your code
ReservationForm.tsx
type Receipt = { reservationId: string; seat: string };

async function reserveSeat(_seat: string): Promise<Receipt> {
	throw new Error('reservation failed');
}

export function ReservationForm() {
	async function submit(seat: string) {
		try {
			const receipt = await reserveSeat(seat);
			console.log(`Reserved ${receipt.seat}`);
		} catch (error) {
			// The same catch receives sold-out, invalid, and invariant failures.
			console.error('Could not reserve a seat', error);
		}
	}

	return <button onClick={() => submit('12A')}>Reserve 12A</button>;
}

07 / The parts to watch

Severity is not the same as process policy.

Keep these boundaries explicit before you reach for a universal error helper.

Expected does not mean harmless

A sold-out seat is expected but still deserves telemetry, a race-safe write, and a useful response. “Expected” means the caller has a valid action, not that the system should ignore it.

Unrecoverable does not always mean the whole process dies

A defect should escape the normal business result. The boundary may restart one worker, fail one request, enter read-only mode, or stop the process. Choosing that blast radius is an operational decision, not this lesson’s automatic conclusion.

Do not catch, classify, and forget

A broad catch that returns 500 can be a safe outer boundary if it preserves the incident. It is dangerous when it converts an invariant violation into “try another seat” or quietly logs and continues with corrupted state.

Result is not a severity guarantee

A result union is explicit, but its cases are only as honest as the contract. Keep defects out of ExpectedFailure; otherwise exhaustive handling merely makes the wrong behavior pleasant to write.

Recognition comes after classification

Whether callers use error kinds, sentinels, causes, or classes is the next design axis. See Kinds and sentinels after deciding which failures deserve normal caller actions.

08 / Make the call

Put the boundary where the recovery authority lives.

Return an expected failure when the caller can take a valid product action. Let an invariant violation escape that normal contract to the boundary that can preserve evidence and choose a safe blast radius. In TypeScript that may be a typed result plus a thrown defect; in Go it is usually an error for expected outcomes and a deliberately owned panic boundary for impossible state.

WhyCan the caller act?

Another seat and corrected input are valid next steps.

WhatWhat belongs in the result?

Only expected cases the caller can handle as normal behavior.

ConstraintWhat must the boundary keep?

Enough context to alert, keep evidence, and contain the damage.

FallbackWhat if the ledger is wrong?

The invariant violation escapes the result and reaches the boundary that owns recovery.

Reconsider whenWhere did authority move?

A new boundary, recovery policy, or business outcome changes the contract.

Further reading: Go’s panic and recover and TypeScript narrowing. The next lesson asks how callers recognize the failure once it has been classified.

09 / Take the idea with you

Explain the reservation without saying “unrecoverable.”

“If the seat is gone, we tell the customer and they pick another. If our own ledger says a seat is held twice, the request stops and someone is alerted, because no customer choice can fix it.” That tells a reviewer who acts on each failure. When the reviewer wants the words, the first is an expected failure and the second is a defect.

Before moving on, jot down one failure your code shows to users that no user can fix, and one it throws that a user could.

Connections to follow nextRelated lessons

Take the reservation into your editor. Add a “seat under maintenance” outcome, decide which path it belongs on, and check what the boundary does with it.

Back to Concepts & practices →