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
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();
} 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.
Throw / catch
Does control leave the return path?
The function throws a failure and a caller establishes a try/catch boundary.
Nullable return
Did a value arrive?
The function returns a value or undefined. It is compact when absence is the
complete answer.
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).
Tagged result
Which state did the function produce?
A discriminant makes success and failure separate cases with data for the caller.
| Question | Throw | Nullable | Value + error | Tagged 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.
Keep the discount job fixed. Change the outcome.
offer retry
Discount service is temporarily unavailable. Retry after 5s.
show no discount
undefined says that no discount arrived; it cannot distinguish invalid, missing, and unavailable.
offer retry
Discount service is temporarily unavailable. Retry after 5s.
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.
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() };
} 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
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));
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.
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.”
Report
Return or throw only what the operation can identify honestly.
Interpret
Turn a known outcome into form feedback, absence, retry, or success.
Contain
Keep unexpected failures from being mistaken for a normal user outcome.
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.
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.
Apply a value, show absence, repair input, retry, or stop.
Choose the smallest path that still carries the required reason and data.
The reason, and data such as a retry delay, reach the caller intact.
A defect escapes the normal result instead of posing as one of its reasons.
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
- Expected vs. unrecoverable asks which failures belong in this normal contract at all.
- Kinds and sentinels asks how a caller recognizes the failure once it arrives.
- Discriminated union results builds the tagged result into a whole save workflow.
- Go’s error protocol follows the value-plus-error pair through wrapping and matching.
- Result types & combinators chains several result-returning steps without a branch at each one.