← Concepts & practices
Choice Errors, results, and recovery

How a function reports failure

What does the caller have to do next?

A function can fail without telling its caller how to recover. Follow a discount lookup through four failure channels, then change the caller’s requirements and see which information disappears or becomes explicit.

TypeScriptGo One lookup. Four ways to say no.

01 / The decision

“No” is not one caller action.

A checkout asks whether code SAVE10 applies. The code may be valid, absent, malformed, or temporarily unavailable. The caller needs different next steps: apply the discount, show a form error, show that no discount exists, or offer a retry.

Read the familiar starting callTypeScript and Go · the channel each language reaches for first
TypeScriptstarting channels
reports.ts
export function byThrow(condition: Condition): Discount {
	const failure = failureFor(condition);
	if (failure) throw new ReportedFailure(failure);
	return discount();
}

export function byNullable(condition: Condition): Discount | undefined {
	return failureFor(condition) ? undefined : discount();
}
Gostarting channels
reports.go
func Lookup(condition Condition) (Discount, error) {
	switch condition {
	case Valid:
		return Discount{Code: "SAVE10", Percent: 10}, nil
	case NotFound:
		return Discount{}, Failure{Kind: FailureNotFound, Message: "That code does not exist."}
	case Invalid:
		return Discount{}, Failure{Kind: FailureInvalid, Message: "Enter a valid discount code."}
	case Unavailable:
		return Discount{}, Failure{Kind: FailureUnavailable, RetryAfterSeconds: 5, Message: "Discount service is temporarily unavailable."}
	default:
		return Discount{}, errors.New("unknown lookup condition")
	}
}

TypeScript reaches for a throw or an undefined. Go has no expected-failure exception path. Its ordinary starting point is a pair: value, err := Lookup(code). The language makes the second return visible, but the error’s categories remain a separate contract.

The failure channel is part of the caller’s contract. It tells the caller where to look, whether the path is visible in the signature, and how much information can survive the trip. It does not by itself decide whether the failure is a bug or an expected outcome.

That last distinction matters. A form mistake belongs in a normal result the caller can act on. A violated internal invariant should not quietly become “no discount.” The next lesson makes that boundary its own decision.

02 / The alternatives

Four paths, different promises.

Each position is reasonable under some constraint. The important comparison is not how little code it takes to write the function; it is what the caller must remember to do when its job grows.

A

Throw / catch

Does control leave the return path?

The function throws a failure and a caller establishes a try/catch boundary.

Useful for an exceptional boundary; expected failure is absent from the signature.
B

Nullable return

Did a value arrive?

The function returns a value or undefined. It is compact when absence is the complete answer.

Useful for one absence state; all reasons for absence collapse together.
C

Value + error

What value and what error came back?

The result carries the value and an error side by side, like Go’s (T, error).

Useful and conventional; the pair needs disciplined checking and an open error contract.
D

Tagged result

Which state did the function produce?

A discriminant makes success and failure separate cases with data for the caller.

Useful for several expected outcomes; more ceremony and manual propagation in many languages.
What the caller can rely on
QuestionThrowNullableValue + errorTagged result
Visible in signature?No, by convention.Only absence.Yes, as a second value.Yes, as explicit cases.
Can it carry a reason?Yes, if the thrown value does.No, without another channel.Yes, through the error.Yes, in the failure case.
What can be forgotten?The catch boundary.The distinction between reasons.To check the error before the value.To handle a new case or propagate it.
Where does it fit?Exceptional or unrecoverable paths.Absence is all the caller needs.Open package protocols.Several expected cases need named actions.

A tuple and a tagged result are not rivals in every codebase. The pair follows a familiar protocol; the result makes “one side or the other” visible and can make case data exhaustive in TypeScript. The choice changes when the caller’s required behavior changes.

03 / Change a constraint

Make the caller do more than say “it failed.”

The lab keeps the lookup fixed and changes the outcome. First see success. Then ask the same four channels to distinguish missing, invalid, and unavailable. Before reading the cards, predict which path still has the data your caller needs.

A controlled comparison

Keep the discount job fixed. Change the outcome.

Runs TypeScript in your browser
Caller asksApply SAVE10
Lookup outcomeThe service is unavailable
Caller mustChoose a next step
Throw / catchfailure

offer retry

Discount service is temporarily unavailable. Retry after 5s.

Nullable returnambiguous

show no discount

undefined says that no discount arrived; it cannot distinguish invalid, missing, and unavailable.

Value + errorfailure

offer retry

Discount service is temporarily unavailable. Retry after 5s.

Tagged resultfailure

offer retry

Discount service is temporarily unavailable. Retry after 5s.

The lab shows what information reaches this caller. It does not schedule a retry, log a stack, or turn an unexpected defect into an expected result.

The nullable result is not “wrong.” It is under-specified for the unavailable case. A pair or a result preserves a reason because the function put one in its contract. Throwing can preserve it too, but only after the caller has found the right control-flow boundary.

04 / Compare the source

Let the signature show where failure lives.

TypeScript can model all four positions. Go’s native convention is the explicit pair, while its generic wrapper makes a result-like state possible without pretending that Go has a built-in closed sum type.

TypeScripttagged result
reports.ts
export function byResult(condition: Condition): Result<Discount, Failure> {
	// failureFor throws for a condition outside the contract: a defect escapes
	// instead of becoming a Failure the caller would treat as a user outcome.
	const failure = failureFor(condition);
	return failure ? { ok: false, error: failure } : { ok: true, value: discount() };
}
Gotagged result
reports.go
type Result[T any] struct {
	Value   T
	Failure *Failure
}

func LookupResult(condition Condition) Result[Discount] {
	value, err := Lookup(condition)
	if err == nil {
		return Result[Discount]{Value: value}
	}
	var failure Failure
	if errors.As(err, &failure) {
		return Result[Discount]{Failure: &failure}
	}
	// Any other error is a defect, not a fourth user outcome: it must not
	// become a form error or "no discount", so it escapes the result.
	panic(fmt.Errorf("lookup defect: %w", err))
}
Read the TypeScriptA closed local union

Result<Discount, Failure> has one ok: true case and one ok: false case. Narrowing on ok makes the matching field available. The function still has to decide how to convert external data into this local result.

Read the GoA pair and a generic wrapper

Lookup returns (Discount, error); callers must check err. Result[T] demonstrates a result-like wrapper, but its pointer field is a convention that tests and constructors must protect. Go’s ordinary error protocol remains the idiomatic choice here.

See the complete programsCopyable source plus invocation
TypeScriptcomplete file
reports.ts
export type Condition = 'valid' | 'not-found' | 'invalid' | 'unavailable';
export type Channel = 'throw' | 'nullable' | 'tuple' | 'result';

export type Discount = Readonly<{
	code: string;
	percent: number;
}>;

export type Failure =
	| { kind: 'not-found'; message: string }
	| { kind: 'invalid'; field: 'code'; message: string }
	| { kind: 'unavailable'; retryAfterSeconds: number; message: string };

export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type Observation = Readonly<{
	channel: Channel;
	condition: Condition;
	path: 'success' | 'failure' | 'ambiguous';
	action:
		'apply discount' | 'show form error' | 'offer retry' | 'show no discount' | 'escalate defect';
	detail: string;
}>;

function discount(): Discount {
	return { code: 'SAVE10', percent: 10 };
}

function failureFor(condition: Condition): Failure | null {
	switch (condition) {
		case 'not-found':
			return { kind: 'not-found', message: 'That code does not exist.' };
		case 'invalid':
			return { kind: 'invalid', field: 'code', message: 'Enter a valid discount code.' };
		case 'unavailable':
			return {
				kind: 'unavailable',
				retryAfterSeconds: 5,
				message: 'Discount service is temporarily unavailable.'
			};
		case 'valid':
			return null;
		default:
			// A condition outside the contract is a defect, not a fourth user outcome.
			throw new Error('unknown lookup condition');
	}
}

export function byThrow(condition: Condition): Discount {
	const failure = failureFor(condition);
	if (failure) throw new ReportedFailure(failure);
	return discount();
}

export function byNullable(condition: Condition): Discount | undefined {
	return failureFor(condition) ? undefined : discount();
}

export class ReportedFailure extends Error {
	readonly failure: Failure;

	constructor(failure: Failure) {
		super(failure.message);
		this.name = 'ReportedFailure';
		this.failure = failure;
	}
}

export function byTuple(condition: Condition): [Discount | null, Failure | null] {
	const failure = failureFor(condition);
	return failure ? [null, failure] : [discount(), null];
}

export function byResult(condition: Condition): Result<Discount, Failure> {
	// failureFor throws for a condition outside the contract: a defect escapes
	// instead of becoming a Failure the caller would treat as a user outcome.
	const failure = failureFor(condition);
	return failure ? { ok: false, error: failure } : { ok: true, value: discount() };
}

function actionFor(failure: Failure): Observation['action'] {
	return failure.kind === 'not-found'
		? 'show no discount'
		: failure.kind === 'invalid'
			? 'show form error'
			: 'offer retry';
}

function failureObservation(channel: Channel, condition: Condition, failure: Failure): Observation {
	return {
		channel,
		condition,
		path: 'failure',
		action: actionFor(failure),
		detail:
			failure.kind === 'unavailable'
				? `${failure.message} Retry after ${failure.retryAfterSeconds}s.`
				: failure.message
	};
}

export function observe(channel: Channel, condition: Condition): Observation {
	if (channel === 'throw') {
		try {
			const value = byThrow(condition);
			return {
				channel,
				condition,
				path: 'success',
				action: 'apply discount',
				detail: `${value.code} applies ${value.percent}% off.`
			};
		} catch (error) {
			if (error instanceof ReportedFailure)
				return failureObservation(channel, condition, error.failure);
			return {
				channel,
				condition,
				path: 'ambiguous',
				action: 'escalate defect',
				detail: 'An unexpected error escaped; the caller must not treat it as a normal outcome.'
			};
		}
	}

	if (channel === 'nullable') {
		const value = byNullable(condition);
		return value
			? {
					channel,
					condition,
					path: 'success',
					action: 'apply discount',
					detail: `${value.code} applies ${value.percent}% off.`
				}
			: {
					channel,
					condition,
					path: 'ambiguous',
					action: 'show no discount',
					detail:
						'undefined says that no discount arrived; it cannot distinguish invalid, missing, and unavailable.'
				};
	}

	if (channel === 'tuple') {
		const [value, failure] = byTuple(condition);
		if (failure) return failureObservation(channel, condition, failure);
		return {
			channel,
			condition,
			path: 'success',
			action: 'apply discount',
			detail: `${value!.code} applies ${value!.percent}% off.`
		};
	}

	const result = byResult(condition);
	return result.ok
		? {
				channel,
				condition,
				path: 'success',
				action: 'apply discount',
				detail: `${result.value.code} applies ${result.value.percent}% off.`
			}
		: failureObservation(channel, condition, result.error);
}

export function runExample() {
	return (['valid', 'invalid', 'unavailable'] as Condition[]).map((condition) => ({
		condition,
		throw: observe('throw', condition),
		nullable: observe('nullable', condition),
		tuple: observe('tuple', condition),
		result: observe('result', condition)
	}));
}

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

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

type Condition string

const (
	Valid       Condition = "valid"
	NotFound    Condition = "not-found"
	Invalid     Condition = "invalid"
	Unavailable Condition = "unavailable"
)

type Discount struct {
	Code    string `json:"code"`
	Percent int    `json:"percent"`
}

type FailureKind string

const (
	FailureNotFound    FailureKind = "not-found"
	FailureInvalid     FailureKind = "invalid"
	FailureUnavailable FailureKind = "unavailable"
)

type Failure struct {
	Kind              FailureKind `json:"kind"`
	Message           string      `json:"message"`
	RetryAfterSeconds int         `json:"retryAfterSeconds,omitempty"`
}

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

func Lookup(condition Condition) (Discount, error) {
	switch condition {
	case Valid:
		return Discount{Code: "SAVE10", Percent: 10}, nil
	case NotFound:
		return Discount{}, Failure{Kind: FailureNotFound, Message: "That code does not exist."}
	case Invalid:
		return Discount{}, Failure{Kind: FailureInvalid, Message: "Enter a valid discount code."}
	case Unavailable:
		return Discount{}, Failure{Kind: FailureUnavailable, RetryAfterSeconds: 5, Message: "Discount service is temporarily unavailable."}
	default:
		return Discount{}, errors.New("unknown lookup condition")
	}
}


type Result[T any] struct {
	Value   T
	Failure *Failure
}

func LookupResult(condition Condition) Result[Discount] {
	value, err := Lookup(condition)
	if err == nil {
		return Result[Discount]{Value: value}
	}
	var failure Failure
	if errors.As(err, &failure) {
		return Result[Discount]{Failure: &failure}
	}
	// Any other error is a defect, not a fourth user outcome: it must not
	// become a form error or "no discount", so it escapes the result.
	panic(fmt.Errorf("lookup defect: %w", err))
}


func Example() map[string]any {
	value, err := Lookup(Unavailable)
	var failure Failure
	_ = errors.As(err, &failure)
	result := LookupResult(Unavailable)
	return map[string]any{
		"lookupError":        failure.Kind,
		"retryAfterSeconds":  failure.RetryAfterSeconds,
		"resultHasValue":     result.Failure == nil,
		"validValue":         mustLookup(Valid),
		"tupleRequiresCheck": value == (Discount{}),
	}
}

func mustLookup(condition Condition) Discount {
	value, err := Lookup(condition)
	if err != nil {
		panic(err)
	}
	return value
}


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

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

05 / Try a decision

Choose for the caller’s recovery.

There may be more than one workable implementation. Pick the one whose contract preserves the behavior the scenario requires.

A code may be invalid or the service may be offline.

The caller must show a field error for one and offer a retry for the other. Which channel keeps that distinction in the contract?

Only “does a discount exist?” matters.

The endpoint is local, the caller has one absence state, and no recovery data is needed. What is a reasonable starting point?

The code crosses a Go package boundary.

Callers must not forget to handle failure, and the package follows Go’s standard error protocol. Which shape fits the language?

Feedback stays on this page; it is not saved.

06 / Give it a real job

A discount form has a recovery contract.

The form’s caller owns the decision, but it cannot make a decision from information the function discarded. A result or value-plus-error can preserve the distinction; a nullable return can be enough when product deliberately has only “discount” and “no discount.”

Function

Report

Return or throw only what the operation can identify honestly.

Caller

Interpret

Turn a known outcome into form feedback, absence, retry, or success.

Boundary

Contain

Keep unexpected failures from being mistaken for a normal user outcome.

reports.ts
export function observe(channel: Channel, condition: Condition): Observation {
	if (channel === 'throw') {
		try {
			const value = byThrow(condition);
			return {
				channel,
				condition,
				path: 'success',
				action: 'apply discount',
				detail: `${value.code} applies ${value.percent}% off.`
			};
		} catch (error) {
			if (error instanceof ReportedFailure)
				return failureObservation(channel, condition, error.failure);
			return {
				channel,
				condition,
				path: 'ambiguous',
				action: 'escalate defect',
				detail: 'An unexpected error escaped; the caller must not treat it as a normal outcome.'
			};
		}
	}

	if (channel === 'nullable') {
		const value = byNullable(condition);
		return value
			? {
					channel,
					condition,
					path: 'success',
					action: 'apply discount',
					detail: `${value.code} applies ${value.percent}% off.`
				}
			: {
					channel,
					condition,
					path: 'ambiguous',
					action: 'show no discount',
					detail:
						'undefined says that no discount arrived; it cannot distinguish invalid, missing, and unavailable.'
				};
	}

	if (channel === 'tuple') {
		const [value, failure] = byTuple(condition);
		if (failure) return failureObservation(channel, condition, failure);
		return {
			channel,
			condition,
			path: 'success',
			action: 'apply discount',
			detail: `${value!.code} applies ${value!.percent}% off.`
		};
	}

	const result = byResult(condition);
	return result.ok
		? {
				channel,
				condition,
				path: 'success',
				action: 'apply discount',
				detail: `${result.value.code} applies ${result.value.percent}% off.`
			}
		: failureObservation(channel, condition, result.error);
}

export function runExample() {
	return (['valid', 'invalid', 'unavailable'] as Condition[]).map((condition) => ({
		condition,
		throw: observe('throw', condition),
		nullable: observe('nullable', condition),
		tuple: observe('tuple', condition),
		result: observe('result', condition)
	}));
}

A server handler can translate a typed failure into an HTTP response, and a frontend can decode that response into its own local result. The wire format is another boundary; a thrown in-memory class does not travel across it automatically.

Build UIs?A form component is a caller of the failure contract, not a place to guess what an absent value meant.

Where it already is in your components

A submit handler already reports failure somehow: React code may catch a rejected promise, while a Svelte form action reads a returned failure. A thin read-only component can render a nullable lookup when “not found” is its only state.

When you have to own it

When the form must distinguish invalid input from an outage, let the boundary return a typed failure and let the component render that decision. Do not make every button handler split an error message or treat every missing value as a retryable outage.

A small discount form catches a rejected lookup and keeps the failure channel at the submit boundary.

ReactAlready in your code
DiscountForm.tsx
type Discount = { code: string; percent: number };

declare function lookupDiscount(code: string): Promise<Discount>;

export function DiscountForm() {
	async function submit(code: string) {
		try {
			const discount = await lookupDiscount(code);
			console.log(`${discount.percent}% off`);
		} catch (error) {
			console.error('Could not apply discount', error);
		}
	}

	return <button onClick={() => submit('SAVE10')}>Apply discount</button>;
}

07 / The parts to watch

A failure channel is not a recovery policy.

Name the edges before you standardize a helper.

Throwing is not automatically exceptional

Expected failures can be thrown, but their absence from the signature makes the contract conventional. A request boundary may be a good place to catch; a deep helper that silently throws for invalid user input makes callers search for invisible control flow.

Nullable is a real contract, just a narrow one

If the only question is “is there a discount?”, Discount | undefined is clear. Once unavailable needs a delay or invalid needs a field, the type has stopped carrying enough information.

Tuple states need discipline

[value, error] permits a caller to observe both, neither, or forget the check unless the convention is strong. Go’s standard library makes the pair familiar; it does not make every misuse impossible.

Result does not mean exhaustive production behavior

A local union can name its expected cases. It cannot make a network response valid, preserve an unknown future case, or decide what to log. Decode at the boundary and keep an unknown path where the deployment requires one.

Accumulation is a different question

This lookup stops at one outcome. A form validator may need every field error at once. That is an accumulation policy, not a reason to claim that every failure channel has been compared here.

08 / Make the call

Choose the path the caller can explain.

Use a nullable return when absence is the complete expected contract. Use a value-plus-error pair when the package follows that open protocol, especially in Go. Use a tagged result when several expected cases need named actions and data. Reserve throwing for a boundary that owns the catch, or for failures that are not normal caller outcomes.

WhyWhat must the caller do?

Apply a value, show absence, repair input, retry, or stop.

WhatWhich channel reports it?

Choose the smallest path that still carries the required reason and data.

ConstraintWhat must survive the trip?

The reason, and data such as a retry delay, reach the caller intact.

FallbackWhat about a failure nobody expected?

A defect escapes the normal result instead of posing as one of its reasons.

Reconsider whenWhat changed?

A second expected failure appears, a payload is needed, or the boundary moves.

Further reading: Go’s “Errors are values” and TypeScript narrowing. The next lesson asks which failures belong in the normal contract at all.

09 / Take the idea with you

Explain the lookup without saying “error.”

“Besides a discount, the lookup can tell you three things: the code is malformed, there is no such discount, or the service is down for a while. Each one arrives with what the caller needs to act on it.” That tells a reviewer what the caller can do next. When the reviewer wants the words, it’s a tagged result, and Go spells it as a value plus an error.

Before moving on, jot down one function in your code that returns null for more than one reason, and what its caller would do differently if it knew which.

Connections to follow nextRelated lessons

Take the lookup into your editor. Add an “expired” reason, give it the data its caller needs, and see which channels can still carry it.

Back to Concepts & practices →