open atlas
↑ Back to track
Code patterns & craft CP · 03 · 03

Types and tests as documentation

Types and tests are executable, machine-checked documentation that cannot rot. A precise type encodes an invariant a comment can only assert: a discriminated union makes illegal states unrepresentable, a branded type stops unit mix-ups, and a named test pins behaviour as a spec.

CP Middle ◷ 20 min
Level
FoundationsJuniorMiddleSenior

// amount must be > 0 sits above a function. Six months later someone refactors the body, a negative amount slips through, and the comment is still there — calm, confident, and wrong. Nobody lied; the comment simply has no power to stop the code from drifting away from it. A comment is a claim the compiler never reads and the test runner never checks.

Now imagine the same invariant written as PositiveInt — a type that can only be constructed by validation at the boundary. The negative amount never reaches the function, because the type system refuses to build the value. The “documentation” became a wall. This lesson is about the two kinds of documentation that cannot rot, because the machine enforces them: types and tests.

Goal

After this lesson you can explain why a precise type documents an invariant a comment can only assert; use discriminated unions to make illegal states unrepresentable and branded types to stop unit/identifier mix-ups; turn an amount must be > 0 comment into a PositiveInt parsed at the boundary; name a test as the behaviour it guarantees so the suite reads as a spec; and recognise the failure mode — nominal types for trivial values and tests that pin implementation instead of behaviour.

1

A comment asserts an invariant; a type encodes it. Documentation rots because there is no mechanism keeping it true. A comment is checked by exactly nobody — not the compiler, not CI, not the next reader who skims past it. The moment the code changes and the comment doesn’t, you have a confident lie sitting next to working code. A type is different in kind: it is part of the program. If the code contradicts the type, the build fails. So the question for any invariant worth stating is not “should I comment this?” but “can I make the compiler the documentation?”

// rots silently:
// precondition: status is one of 'draft' | 'sent' | 'paid'
function advance(invoice: { status: string }) { /* ... */ }

// cannot rot — the compiler is the doc:
type Status = "draft" | "sent" | "paid";
function advance(invoice: { status: Status }) { /* ... */ }

The string version says three states; the union enforces three states. A fourth value is a compile error, not a runtime surprise three deploys later.

2

Discriminated unions make illegal states unrepresentable. The most expensive bugs are not wrong values — they are impossible combinations the type allowed: a request that is both loading: true and has data, an order that is cancelled but still has a shippedAt. A comment (“don’t set data while loading”) is a prayer. A discriminated union deletes the illegal state from the type, so no code path can construct it.

// allows the impossible: loading && data && error all set at once
type Bad = { loading: boolean; data?: User; error?: Error };

// the four real states, and only those:
type Fetch =
  | { tag: "idle" }
  | { tag: "loading" }
  | { tag: "ok"; data: User }
  | { tag: "err"; error: Error };

Now data only exists when tag === "ok". The reader learns the entire state machine from the type, and the compiler rejects any handler that forgets a case. The type is both the documentation and the exhaustiveness check.

3

Branded (opaque) types document units and identity, stopping silent mix-ups. TypeScript is structural: a UserId and an OrderId are both string, so the compiler happily lets you pass one where the other belongs. The classic production incident is exactly this — a function takes (userId: string, accountId: string) and someone swaps the arguments. A brand gives a primitive a nominal identity without runtime cost.

type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type Cents  = Brand<number, "Cents">;

function chargeAccount(user: UserId, amount: Cents) { /* ... */ }
// chargeAccount(orderId, dollars) // ✗ compile error — wrong brand

The brand is the documentation that amount is in cents, not dollars, and that this string is a user id, not any string. But note the cost: this only earns its keep where the mix-up is plausible and expensive. Branding every string is the failure mode — Step 5.

4

A well-named test is an executable spec; the suite is living documentation. A comment describing behaviour drifts; a test describing behaviour fails when the behaviour drifts. The trick is naming: a test called test('works') documents nothing, but test('rejects an amount of zero') is a sentence in the spec that also runs. Read top-to-bottom, well-named tests are the most accurate prose description of a unit that exists — because every line is checked on every commit.

describe("parsePositiveInt", () => {
  it("returns a PositiveInt for a positive integer", () => {
    expect(parsePositiveInt(5)).toEqual({ ok: true, value: 5 });
  });
  it("rejects zero", () => {
    expect(parsePositiveInt(0).ok).toBe(false);
  });
  it("rejects negatives and non-integers", () => {
    expect(parsePositiveInt(-1).ok).toBe(false);
    expect(parsePositiveInt(2.5).ok).toBe(false);
  });
});

You can read those three names and know the contract without reading the body. That is documentation that catches drift — the comment that can run.

Worked example

Replace the comment with a type parsed at the boundary. The starting point is the classic rotting contract:

// amount must be a positive integer — callers, please check!
function applyCredit(accountId: string, amount: number) {
  // ...nothing here actually enforces the comment
  balance += amount;
}

The comment offloads the invariant onto every caller, and nothing fails when one forgets. We move the check to the boundary and return a type that proves it passed — “parse, don’t validate”: instead of asserting the value is good, produce a value whose type means it is good.

type PositiveInt = Brand<number, "PositiveInt">;

function parsePositiveInt(n: number): Result<PositiveInt> {
  if (!Number.isInteger(n) || n <= 0) {
    return { ok: false, error: "amount must be a positive integer" };
  }
  return { ok: true, value: n as PositiveInt };
}

// the function no longer documents the rule in a comment —
// it requires the proof in its signature:
function applyCredit(accountId: AccountId, amount: PositiveInt) {
  balance += amount; // unconditionally safe; the type is the guarantee
}

Now the invariant is enforced in exactly one place (the parser), the signature is the documentation, and the only way to get a PositiveInt is to go through the check. Pair it with a behaviour-named test:

it("applyCredit cannot be called with a non-positive amount", () => {
  const parsed = parsePositiveInt(0);
  expect(parsed.ok).toBe(false); // it never produces the value to pass in
});

The comment is gone, and nothing it claimed can silently become false: the compiler enforces the shape, the parser enforces the value, and the test name records the intent. Three rot-proof documents replaced one hopeful sentence.

Why this works

Why is a type “documentation” and not just a constraint? Because documentation’s job is to communicate intent to the next reader, and a precise type does exactly that — parsePositiveInt(n): Result<PositiveInt> tells the reader the rule (positive integers only), the failure mode (it can fail, here’s the shape), and the guarantee downstream (anything typed PositiveInt already passed). A comment communicates the same intent but with no enforcement, so it degrades into noise the moment it’s wrong. The type communicates intent and keeps itself honest. The senior move is to push the invariant down into the type system so the most-read artefact in the codebase — the signatures — carries the truth, and the compiler is the proofreader who never gets tired.

Common mistake

Two symmetric failure modes. Over-engineering: branding every trivial primitive (Brand<string, "FirstName">) or modelling a boolean as a four-case union. Nominal types pay off only where a mix-up is plausible and the cost of getting it wrong is real — ids, money, units, security tokens. Everywhere else they add ceremony and reading cost for no protection, which is its own kind of rot. Tests that pin implementation, not behaviour: expect(spy).toHaveBeenCalledWith(...) or asserting a private field documents how the code works today, so any refactor turns the suite red even when behaviour is unchanged. That trains the team to ignore or rewrite tests reflexively — the opposite of a trustworthy spec. Name and assert the observable behaviour (“rejects zero”), not the mechanism, so the test survives every refactor that keeps the contract.

Check yourself
Quiz

A function carries the comment `// amount must be a positive integer`. Which change best turns that rotting comment into documentation that cannot drift out of date?

Recap

Comments rot because no tool keeps them true; types and tests are documentation the machine enforces, so they cannot silently drift out of date. A precise type encodes an invariant a comment can only assert: a discriminated union makes illegal states unrepresentable, a branded/opaque type stops unit and identifier mix-ups, and readonly documents immutability. The senior pattern is parse, don’t validate — check at the boundary and return a value whose type means the check passed, so the signature becomes the doc and the compiler the proofreader. A behaviour-named test is an executable spec that pins intent and catches drift on every commit. The failure mode is symmetric: nominal types for trivial values add ceremony without protection, and tests that assert implementation instead of behaviour go red on harmless refactors. Reach for the type system where the invariant is real and the mistake is expensive; name tests for behaviour, never mechanism.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.