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
/** 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();
} // 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.
Everything throws
One catch receives both classes.
Expected failure and invariant violation leave the return path through the same control-flow channel.
Everything returns
One result carries every state.
Both a customer-correctable outcome and a broken invariant become values the caller must inspect.
Split by severity
Expected returns; defects escape.
The normal result lists actions the caller can take. An invariant violation reaches its owning boundary.
| Question | Everything throws | Everything returns | Split 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 cost | Invisible 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.
Keep the reservation job fixed. Change the severity.
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.
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.
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.
/** 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() };
} // 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
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));
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?
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.
Classify
Return sold-out or invalid when the customer can act; identify impossible state as a defect.
Recover normally
Offer another seat, show validation, or confirm the receipt.
Contain
Stop, alert, and choose a safe blast radius for an invariant violation.
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.
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.
Another seat and corrected input are valid next steps.
Only expected cases the caller can handle as normal behavior.
Enough context to alert, keep evidence, and contain the damage.
The invariant violation escapes the result and reaches the boundary that owns recovery.
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
- How a function reports failure compares the channels an expected failure can travel on.
- Kinds and sentinels asks how callers recognize a failure once it has been classified.
- Error boundaries in UI is the frontend boundary that contains what escapes a component.
- Making illegal states unrepresentable removes some invariant violations before they can happen.
- Invariants and example tests names what a seat reservation must keep true, then tests its edges.