Skip to content

Lesson 1 of 9 · 20m

Make Illegal States Unrepresentable

Use the type system as your first security boundary: if a bad state can't be expressed, it can't ship.

An invoice service once let amount flow through as a string, and a client posting "-500" got a negative refund. The code worked exactly as written — the data model didn't. Every bug in production is a state that shouldn't exist; your first job is making it unrepresentable, not catching it later.

Strings are not types

// before: every field is a grab-bag
interface Subscription { status: string; planId?: string; renewsAt?: Date }

// after: the states are the type
type Subscription =
  | { kind: "trial"; expiresAt: Date }
  | { kind: "active"; renewsAt: Date; planId: string }
  | { kind: "cancelled"; cancelledAt: Date };

Now planId cannot exist on a trial — the compiler deleted a whole bug class. Discriminated unions exist in TypeScript, Rust, Scala, and (with a little ceremony) Python dataclasses. Use them wherever "this field depends on that field" appears.

Parse, don't validate

Validation checks isEmail(x) and keeps passing around a string — every downstream consumer has to trust that somebody checked. Parsing produces a new type: Email.parse(raw) returns an Email or fails, and code that takes Email never sees the unvalidated form. Same in Python:

@dataclass(frozen=True)
class Quantity:
    value: int
    def __post_init__(self) -> None:
        if self.value <= 0:
            raise ValueError(f"quantity must be positive, got {self.value}")

After that, Quantity is a guarantee, not a convention. Do this at the boundary — HTTP handler, CLI arg, config file — and the interior of your program runs on trusted shapes.

Null is a state — name it

lastLogin: Date | null — what does null mean? Never logged in? Data missing? Deactivated? If they differ, model them differently. Rust forces this with Option<T> and makes you unwrap deliberately; borrow the discipline even in permissive languages.

The rule of thumb: count the states your fields permit, then count the states your business rules permit. The gap between those two numbers is your bug budget. Shrink the first number toward the second and entire categories of defensive if checks disappear — they stop being necessary rather than being added.

Try this: find a struct or dict in your code with three or more booleans (or a status string plus optional fields). List which combinations are actually legal, then rewrite it as an enum or discriminated union. Count how many states you deleted.

Field notes

  • Booleans multiply: three bools is eight states; usually four are legal.
  • Don't over-engineer: two layers is enough — parse at the boundary, trust inside.
  • This is a security control, not aesthetics. When "a string that is SQL" and "a string that is a username" are different types, injection dies at the type checker.
  • Refactor toward it incrementally: tighten one field's type per pull request, and let each change prove itself in code review rather than attempting a grand rewrite.