Weak types to strong ones
Eight changes, each one deleting a kind of bug instead of a bug
A payments service with three providers, two verification flows and a lifecycle that must not run backwards. It starts with no types at all and ends where the mistakes it used to make cannot be written down. The last change is a deletion: by then the code that guarded against those mistakes has nothing left to guard.
Python and TypeScript, side by side at every stage, with the places they cannot follow each other called out. Click any symbol and the panel says what the tool knows about it: its type, whether that was written down or worked out, where it is defined, what calls it.
Generated Running fr over the
code beside it produced every answer in the panel, and
cargo test --test types_data captured them. The stages live in
tests/types_tutorial and a test asserts the progression, so a stage
that stopped knowing more than the one before it would fail the build.
What "unrepresentable" means here, and what it does not
Neither language enforces any of this at run time. Python's annotations do nothing without a checker, and TypeScript strips its own before a line executes. Unrepresentable means two things together: your checker rejects it, and one parse at the boundary turns untrusted input into a domain value or turns it away.
That boundary is the whole idea, and other people made it: Alexis King's Parse, Don't Validate is the short version, Yaron Minsky's "make illegal states unrepresentable" the older phrase, and Scott Wlaschin's Domain Modeling Made Functional the worked one. The stages below apply those arguments to code that already exists, one refactoring at a time. Fowler's catalogue names most of them under Primitive Obsession.
Read the panel as a scoreboard
The tool answers a symbol's type from what the source declared, or from what follows from it: a literal, a class constructed, a declared return type. It always says which of the two it is doing. At the first stage almost everything is worked out, and a good deal is not known at all; by the last, almost everything is written down. Watch that number move from stage to stage.
Where the two languages stop agreeing
| Idea | Python 3.14 | TypeScript 7 |
|---|---|---|
| A closed set of names | StrEnum, a real object at run time, with members you can iterate | a union of string literals; enum exists and is best avoided |
| A name of its own for a string | NewType, erased and free | a brand with unique symbol, which is a trick and not a feature |
| Immutability | @dataclass(frozen=True), enforced when it runs | readonly, a note to the checker and nothing more |
| A tagged union | a union of classes; match narrows by class | a discriminant field is required, and switch narrows on it |
| Proving every case is handled | assert_never | const _: never = x |
| A private constructor | convention: look in Money.of, though nothing sends you there | private constructor, enforced by the checker |
The last row is the sharpest. TypeScript can stop you calling
new Money(-1). Python cannot stop you calling
Money(-1, USD); it only asks that you do not. Both make the invalid value hard to reach by accident, and a
refactoring buys you that much. Neither makes it impossible, and a page that told
you otherwise would be selling something.
What stayed a run-time check, and why
One check survives to the last stage: a refund may not exceed what was captured. It relates two values instead of naming a state, and no type here can say "no larger than the number in that other object". Languages that can say it exist; these two cannot.
The technique stops there. Everything above it, the states and the identifiers and the currencies and the verifications, went from a check somebody had to remember to a shape the checker enforces. This one did not, and pretending otherwise would undo the point of the other seven.