7. No Surprises: Least Astonishment and Contracts
Chapter 6 hid an invariant inside an object so the outside world couldn’t break it. But objects and functions collaborate, and every boundary between them is a promise: this name, these parameters, this return type all tell a caller what to expect. This chapter is about keeping those promises — and about failing loudly, right where you stand, when one is broken. The principle that governs it has a name: least astonishment. Code should do what it says.
The Itch
Section titled “The Itch”Here is a function used all over checkout-lite:
def apply_discount(price: float, percent: float) -> float: return price - price * percent / 100export function applyDiscount(price: number, percent: number): number { return price - (price * percent) / 100;}What is percent? The name suggests a percentage, but nothing says whether to pass
20 for twenty percent or 0.2. So callers guess, and they guess differently. One
team writes apply_discount(100, 20) and gets 80. Another writes
apply_discount(100, 0.2), expecting twenty percent off, and gets 99.80 — a 0.2%
discount, silently wrong, shipped to production where it quietly under-charges for a
quarter.
And it gets worse. apply_discount(100, 150) returns -50 — a negative price, no
error, no complaint. That value flows into the cart, into the payment request, and
finally crashes the payment gateway with an error about negative amounts — three
modules away from the discount that caused it. The engineer paged at 2 a.m. starts
debugging payments, because that is where the explosion happened, not where the bomb
was planted.
Both failures share one root: the function made a promise in its name and signature that it neither clarified nor kept. The reader was astonished — and astonishment, in code, is just the gap between what a name leads you to expect and what the code actually does.
The Concept
Section titled “The Concept”The Principle of Least Astonishment says a unit should behave the way a reasonable reader of its name and signature expects. Close the gap and bugs that lived in the gap disappear. The way you close it is with contracts — the promises a function makes, stated explicitly:
- A precondition is what must be true for the call to be valid — the caller’s
responsibility. For our function:
0 <= percent <= 100. - A postcondition is what the function guarantees in return — its responsibility. Here: the result is non-negative and rounded to cents.
- An invariant is what stays true throughout — across a loop, or across an object’s whole life (Chapter 6’s cart, whose total always equalled its discounted items).
And the rule that makes contracts bite is fail fast: when a precondition is violated, stop immediately, at the boundary, with an error that names its own cause. The alternative — returning a plausible-but-wrong value — is what sent our negative price on its long journey to the wrong stack trace.
flowchart TD
Bad["apply_discount(100, 150)"] --> Q{"precondition<br/>checked?"}
Q -->|"fail fast (raise here)"| Near["error: percent must be 0–100<br/>error names its cause"]
Q -->|"no check"| Corrupt["returns -50"] --> Far["crash in payment gateway,<br/>3 modules away"]
The distance between the bug and its symptom is the cost of not failing fast. Closing that distance is most of what this chapter buys you.
Before / After
Section titled “Before / After”The fix is to write the promise down and enforce it: a name that states its units, a comment or doc that states the contract, a precondition that fails fast, and a defined postcondition.
Before
Section titled “Before”def apply_discount(price: float, percent: float) -> float: return price - price * percent / 100export function applyDiscount(price: number, percent: number): number { return price - (price * percent) / 100;}def apply_percentage_discount(price: float, percent: float) -> float: """Return `price` reduced by `percent` percent.
Precondition: 0 <= percent <= 100. Postcondition: result is non-negative and rounded to cents. """ if not 0.0 <= percent <= 100.0: raise ValueError(f"percent must be in [0, 100], got {percent}") result = round(price * (1 - percent / 100), 2) assert result >= 0.0, "postcondition: a discount cannot create a negative price" return result/** * Return `price` reduced by `percent` percent. * * Precondition: 0 <= percent <= 100. * Postcondition: result is non-negative and rounded to cents. */export function applyPercentageDiscount(price: number, percent: number): number { if (!(percent >= 0 && percent <= 100)) { throw new RangeError(`percent must be in [0, 100], got ${percent}`); } return Math.round(price * (1 - percent / 100) * 100) / 100;}The name now says percentage, so the units stop being a guess. The precondition
raises on bad input — at the boundary, naming the cause. And the postcondition is
guaranteed by construction. Which raises a question worth its own section: when a
language gives you more than one tool for these checks, which one runs in
production — and which one quietly disappears?
Language Notes
Section titled “Language Notes”Both languages reach the same contract from different starting points — and the difference is itself a lesson: the contract is the idea; each language gives you different tools to make it bite at compile time, at runtime, or both.
Python gives you three lightweight tools for contracts, and one rule for choosing between two of them that prevents a real production bug.
Type hints are the cheapest contract — checked before the code even runs, by your
type checker (a guide, in Chapter 3’s terms). percent: float is weak, but types
can do far more, which we will come back to in a moment.
assert versus raise — the rule that matters most. Use raise (a real
exception like ValueError) for preconditions on external or untrusted input: it
must always run. Use assert only for internal invariants and postconditions you
believe cannot fail — a sanity check on your own logic. The reason is sharp:
python -O strips every assert from the bytecode. An assert guarding real input
silently vanishes in production, taking your validation with it. That is why, above,
the precondition on the caller’s percent is a raise, while the postcondition
on our own arithmetic is an assert — the postcondition is a statement about code we
control, and if it ever fails, that is a bug in us, not bad input from them.
Docstrings state the contract in words, for the human caller and for the agent reading your repo as context.
Parse, don’t validate
Section titled “Parse, don’t validate”There is a move stronger than checking input: making invalid input impossible to
pass in the first place. Alexis King named it “parse, don’t validate.” A
validator inspects a value and hands the same wide type onward — after
apply_percentage_discount checks percent, it is still just a float, and the
next function that receives it has no proof it was ever checked, so it is tempted to
check again. A parser instead turns the input into a narrower type that carries
the proof:
@dataclass(frozen=True)class Percent: """A parsed percentage: if one exists, it is valid.""" value: float
def __post_init__(self) -> None: if not 0.0 <= self.value <= 100.0: raise ValueError(f"percent must be in [0, 100], got {self.value}")
def discount(price: float, percent: Percent) -> float: return round(price * (1 - percent.value / 100), 2)Now discount does no checking, because it cannot receive an invalid percentage — an
invalid Percent cannot be constructed. You validate once, at the boundary, where
the raw float becomes a Percent; everything downstream takes the proven type and
trusts it. This is the constructive form of make illegal states unrepresentable,
and it dissolves the assert-versus-raise tension entirely: you raise once, in the
parser, and afterward there is nothing left to re-check.
Be honest about Python, though: this is not Haskell. The type system is gradual and
unenforced at runtime, so the guarantee does not live in the compiler — it lives in
the value object that validates once at construction. The type hint Percent
then documents the proof and lets a type checker flag code that ignores it. Parse,
don’t validate, Python-style, means constructed value objects with validated
boundaries, not faith in mypy. (A stringly-typed argument with a few fixed values
has a lighter parser: an Enum or Literal, so "shiped" simply cannot be passed.)
TypeScript splits the contract across two moments — compile time and run time — and knowing which tool lives in which moment is the whole game.
Types are the cheapest contract, checked by the compiler before a line runs.
percent: number is weak (every number is in range as far as the compiler knows),
but types can carry far more proof than that, which we will return to in a moment.
There is no assert-stripping problem — but there is a sharper one. TypeScript
has no assert statement that the toolchain strips, so the Python -O trap does not
exist. The real trap is subtler: types vanish entirely at runtime. They are
erased during compilation, so a percent: number annotation does nothing to a
value that arrived from JSON, a form field, or a network call — at that boundary you
genuinely have an unknown, and the compiler’s promise covers only the values it
saw. So the rule is: for preconditions on external input, throw a real runtime
error (throw new Error(...), or a custom error class) — the same fail-fast move as
Python’s raise, for the same reason. For your own internal invariants, lean on the
type system instead of a runtime check: model the code so the impossible state
won’t compile.
JSDoc comments state the contract in words, for the human caller and for the agent reading your repo as context.
Parse, don’t validate
Section titled “Parse, don’t validate”This is where TypeScript’s structural type system genuinely shines — more than
Python’s does. A validator checks a value and hands the same wide type onward:
after applyPercentageDiscount checks percent, it is still just a number, and the
next function has no proof it was ever checked. A parser turns the input into a
narrower type that carries the proof — and in TypeScript that narrower type can be
a branded (opaque) type the compiler will refuse to fake:
declare const percentBrand: unique symbol;export type Percent = number & { readonly [percentBrand]: true };
export function percent(value: number): Percent { if (!(value >= 0 && value <= 100)) { throw new RangeError(`percent must be in [0, 100], got ${value}`); } return value as Percent; // the lone place the brand is applied}
export function discount(price: number, pct: Percent): number { return Math.round(price * (1 - pct / 100) * 100) / 100;}Now discount does no checking — it cannot receive an invalid percentage, because
a plain number is not assignable to Percent. The compiler rejects
discount(100, 20) and forces every caller through percent(...), the one parser
that throws. This is the constructive form of make illegal states unrepresentable,
and here the guarantee lives partly in the compiler (a class with a private brand, or
a class Percent whose constructor validates, gives the same protection). You throw
once, in the parser, and afterward there is nothing left to re-check.
The boundary deserves one more move. External input is honestly typed unknown —
not number — so the parser starts by narrowing the type, then validates the value:
export function parsePercentFromInput(raw: unknown): Percent { if (typeof raw !== "number") { throw new TypeError(`percent must be a number, got ${typeof raw}`); } return percent(raw);}Take unknown (never any, which would silently disable the very checks you need)
at the edge, parse it into Percent, and the rest of the program holds a value that
is guaranteed valid by its type. (For a fixed set of string values, the lighter
parser is a string-literal union plus an exhaustive check, so "shiped" simply will
not type-check.)
When NOT to Use
Section titled “When NOT to Use”🤖 AI Collaboration
Section titled “🤖 AI Collaboration”Agents are fluent at producing confident names — which is exactly the risk: a
function called get_user that also writes to the database reads perfectly and
astonishes completely. They also reach for the wrong validation tool — a strippable
assert in Python, a swallowed error or an any cast in TypeScript — and scatter
defensive checks everywhere (overkill). The vocabulary below steers both.
Vocabulary
Section titled “Vocabulary”| You say | The agent hears |
|---|---|
| ”Add a precondition that fails fast” | Throw a real error at the boundary on invalid input (raise / throw); don’t return a wrong value |
| ”State the pre/postconditions in the doc” | Write the contract down for the next caller (docstring / JSDoc) |
| “Don’t use a strippable check for input” | No assert in Python (-O strips it); no swallowed error or any cast in TS |
| ”Parse, don’t validate” | Turn input into a type that carries its own proof; trust it after |
| ”Make illegal states unrepresentable” | Use a value object / Enum / Literal (Python) or a branded type / literal union (TS) so the bad value can’t exist |
| ”This name is misleading” | The behavior doesn’t match the promise — rename or split |
Prompt templates
Section titled “Prompt templates”This function does no validation. Add a precondition that throws a real, named error
at the boundary on invalid input — in Python a raise (not an assert, which -O
strips); in TypeScript a thrown Error/custom error (not a swallowed default).
State the pre- and postconditions in the doc comment, and make the name reflect what
it actually does and in what units. Don’t add defensive checks to functions that only
receive already-validated data.
Replace this repeated validation with a parsed type that cannot be constructed
invalid — in Python a small frozen dataclass (or an Enum/Literal for a fixed
set) validated once at construction; in TypeScript a branded/opaque type with a single
parser function (or a literal union) that throws on bad input. Change the downstream
functions to take that type and stop re-checking. At the system edge, accept the input
as unknown and narrow it in the parser.
Review checklist
Section titled “Review checklist”- Does the name match the behavior — no hidden side effects (a
get_that writes)? - Preconditions on external input throw a real error, and fail at the boundary?
- No strippable/erasable check guarding input (
assertin Python; ananycast in TS)? - Where a value has a real invariant, is it a parsed type rather than re-checked?
- Reverse check: any defensive walls on already-trusted internal data?
Agent failure modes
Section titled “Agent failure modes”- The strippable check for input. In Python the agent guards external input with
assert— it works in tests and silently vanishes under-O. In TypeScript it trusts a type annotation on what is really anunknownfrom the wire. Insist on a real runtime check that throws at the boundary. - The confident misnomer. It names a function for what you asked, not for what the
code does —
validate_orderthat also saves it. Chapter 1’s plausible-but-wrong, wearing a function name. Read the body against the name. - Error swallowing. Instead of failing fast, it returns
None/nullor a default on bad input, pushing the crash downstream. Fail at the source. - The defensive wall. Asked for “robust” code, it validates every argument of every helper. Boundary, not everywhere.
Key Takeaways
Section titled “Key Takeaways”- The Principle of Least Astonishment: code should behave as its name and signature promise. A bug in the gap between promise and behavior is the most expensive kind, because it hides in plain sight.
- Make promises explicit as contracts: preconditions (the caller’s duty), postconditions (yours), invariants (always true). And fail fast — stop at the boundary with an error that names its cause, instead of letting a wrong value crash far away.
- Pick the check that runs in production: in Python
raisefor preconditions on external input,assertonly for internal invariants (-Ostrips it); in TypeScript a thrown error for input (types are erased at runtime), the type system for internal invariants. - Parse, don’t validate: turn input into a type that carries its own proof, so downstream code can’t receive an invalid value and never re-checks. In Python the proof lives in a value object validated at construction; in TypeScript a branded type with a single parser puts part of that proof into the compiler.
- Right-size it: validate at the boundary and trust inside; don’t build assertion walls or a bespoke type for every primitive.
- Glossary terms added: Principle of Least Astonishment · precondition · postcondition · invariant · fail fast · parse, don’t validate.