fun-refactor

1. The program we will fix

Albert Hargreaves builds bicycles, and his clerk Ernest wrote the program that runs the books: a bill of materials for the parts, an invoice for each customer, everything in old money at twelve pence to the shilling and twenty shillings to the pound. Ernest didn't annotate anything. His file runs fine in Python and in Node, and it has been getting the sums wrong for months.

Every cell on this page is editable, so try it. At the bottom of the file, print bill("roadster", "Mrs. Pemberton") and press Run. Ernest wrote that call with the arguments the wrong way round, and the invoice comes back made out to the roadster.

That's one of five mistakes waiting in the file:

  • invoice_line takes its four arguments in any order.
  • Prices are plain numbers, and some functions count in pence while others count in pounds.
  • advance compares the status against darft, so no invoice leaves the draft state and no customer gets billed.
  • bill takes its two ids in either order, as you just saw.
  • load_bom_line passes every field on as text, quantities and prices included.

Now press the checker button. Neither checker finds those five domain mistakes. Python's ty accepts the unannotated functions under gradual typing, while tsc --strict stops at their implicit any parameters. Albert can keep finding the real mistakes the hard way, one angry customer at a time. There has to be a better way. Work down the page and take Ernest's mistakes one at a time, changing the types until each one stops being something anybody can write.

2. Simple types

Start with invoice_line. It takes a description, a price, a quantity and a tax flag, and Ernest passes them in whatever order comes to mind.

Type the four arguments str, int and bool. Now swap the price and the quantity in the call and run the checker: ty gives you the line and the types that don't fit. The annotations cost nothing at run time, because Python never evaluates them and TypeScript strips them out.

Money needs more. A price and a discount rate are both numbers, so apply_discount(rate, total) runs perfectly well. Ernest wrote it that way round once, and ten percent off a bill of 1250 pence came back as minus 125 pence.

Wrap the two in Pence and Rate, then write the backwards call again. It won't compile, so nobody can post that discount. TypeScript erases the brand; in Python each constructor call costs about 20 ns.

Some customers add a gift note and most don't, so the note is a str | None. Ernest cast it to str to quiet the checker, and the crash arrived with the first noteless order.

Write the branch instead of the cast: inside if note is not None, use the note as text. Then put a function call between the test and the use and press Type check again: the None is back, because the narrowing only follows simple tests. The branch costs one comparison.

3. Aliases and units

The parts file measures spokes by count and tubing by the metre, and to the program both are numbers. Ernest has swapped the two columns before. An alias won't stop him: it renames a type without changing it, so a Meters alias takes a spoke count without complaint.

Declare a NewType in Python or a brand in TypeScript, one for each unit, and hand a spoke count to cut_tubing. It won't compile. TypeScript erases the brand; NewType leaves the value alone and costs about 20 ns per call.

Watch the arithmetic, though. Add two branded lengths and the sum comes back a plain number, brand gone.

Give the quantities typed operations and multiply a width by a height: you get an area, a unit neither factor had. Every quantity becomes an object, about 135 ns to build in Python. Use this for a bill of materials, not inside a numeric loop.

Now Ernest's typo. While the status is a plain str, darft is as good a value as draft.

Write the three real statuses into the type, then type darft anywhere and press Type check. It isn't a status any more, so the typo can't get back into the books. Both languages erase literal types before the program runs, so this costs nothing.

4. Domain types

Customers write their address on the order form, and anything can arrive: a stray space, a line with no @ in it, an empty box. Ernest checked the field once in checkout and passed a plain str on, so everything after it took the address on trust.

Return an EmailAddress from parse_email and ask for one in send_receipt. Hold an EmailAddress now and you know the check passed. That's a domain type: it states a fact about your problem. Keep the constructor in one module, because a cast written elsewhere can still forge one. The type is free, and the check runs once, where you put it.

A customer id and a product id have the same shape, which is how the roadster got invoiced in section 1.

Give each id a type and try Ernest's backwards call again. It won't compile. Neither type survives to run time.

Ernest's escaper took a note and gave back a plain str, so nothing marked which notes had already been through it. One note went through twice and the ampersands came out doubled.

Return an HtmlText from the escaper and accept only HtmlText in the renderer. Raw text can't reach the page, and escaping twice stops type-checking. Each value costs one allocation.

Albert ships by two carriers, the Royal Post and a village courier, and each quotes by the kilogram. Here the two languages disagree about what counts as a carrier. TypeScript matches shapes everywhere. Python matches names, so a courier class from somebody else's package doesn't count, however right its quote_pence method looks, and Ernest can't edit that package.

Declare a Protocol and Python matches shapes too: the outside class counts as it stands, with no inheritance asked for. A Protocol exists for the checker alone unless you mark it runtime-checkable.

5. States and stages

A distinct name keeps two values apart, and you can still build nonsense out of honest parts. Ernest's payment record carries a settled flag beside an optional receipt, so a payment can say settled and carry no receipt. Albert found one in the ledger in March and lost an afternoon to it.

Split the record in two: give Settled a receipt field and Pending none. Now try to build a settled payment without a receipt. There's no field to leave empty and no constructor that takes one. You pay for one tag field and a comparison to read it.

Stages go the same way. Ernest's file relies on everyone remembering that an invoice gets sent before it gets paid, and the only guard is a comment.

Let send return a SentInvoice and let record_payment accept nothing else. Try to pay a draft: there's no such method to call. Each transition allocates one object, and the types are free. The pattern goes by typestate.

6. At the border

The outside world hands the program text. The invoicing job takes its settings on the command line, and Ernest read them into a dictionary of strings. connect checks that the port is a number and in range, then report checks it again, because a checked dictionary and an unchecked one are the same type.

Check the port once in parse_argv and return a Config. Then ask for a Config downstream and delete the checks in connect and report. Every function downstream gets a port that was checked once. Alexis King's slogan for this is: write functions on the data you wish you had.

One honest limit: the type records that the check ran, and the world keeps moving. The port was free when you parsed it, and somebody else can still take it before you bind it.

Watch what you convert into, though. A type wide enough to hold anything the input might hold rules nothing out. Ernest reached for a faithful Json type when the supplier began sending the price list as JSON, and it spread through every signature that touches a line. Each field access then needs a cast, and a cast asserts without checking.

Parse each line at the door into a Line, and stop the Json type there. Downstream, read line.pence without a cast. Remember that decoding isn't parsing: json.loads hands you Any and JSON.parse hands you unknown, and turning either into your own types is a step you write yourself.

Copying an outside system's shape into your own types springs the same trap. Resources with a kind, a name and a bag of attributes describe a config file perfectly and tell the program nothing: a server and a database share one type, every attribute is optional, and a misspelled key waits for whoever runs it.

Model the two fields the program actually uses and read the foreign shape in one place. Then ask that question of every type you add: what does it rule out? If every value the source can produce still fits, you've written documentation.

7. Functions as values

The picking list sorts the invoice lines and takes the sort key as an argument. Ernest typed that argument loosely, so a key that expects something other than an invoice line goes in without complaint and fails when the sort calls it.

Write down what the key takes and what it returns. Hand the sort a key it can't call now and the code won't compile. The call doesn't change.

Take the handler table next, one handler per order status.

Declare one handler shape and hold every entry to it. Wire a handler to the wrong case and you find out while you're typing, not when an invoice takes the wrong turn. At run time it's the same lookup and the same call.

The supplier's price service drops connections, so Ernest wrapped fetch in a helper that retries it. The helper took any function and returned any function, and the wrapped fetch came back accepting anything at all.

Write the wrapper so it keeps the signature, and every argument check comes through with it. Either way the wrapper adds one call frame.

8. Composition and monads

A monad is just a monoid in the category of endofunctors.

Anonymous

You already use three:

  • a comprehension that flattens a list of lists,
  • await, which unwraps an answer that arrives later,
  • the if that clears away a value that may be missing.

Those three share one shape, called a monad: a way in (Ok, Promise.resolve, a one-element list) and a chaining step that returns the same wrapper (and_then, flatMap, bind, then, await). Use map for a step that can't fail. The ones you'll meet by name:

  • Option: the answer may be missing. Neither language ships it; | None and optional chaining cover it.
  • Result: the step failed, and the reason matters.
  • Promise: the answer arrives later; chain it with await.

Reach for your own language's tools first: None narrowing, exceptions at the edge, comprehensions, async and await. Hand-rolled monads aren't idiomatic in either language, and most teams treat them as friction. Build Result and stop there. Ernest's quote can fail three ways: the quantity isn't a number, the stock won't cover it, or the price is missing. All three come back as None, so Albert learns that the quote failed and nothing more.

Return Ok with the price or Err with the reason, then thread the steps with and_then, which runs the next step only when the last one produced a value. Albert gets told which of the three went wrong. The failure sits in the signature now, so a caller has to open the Result to reach the price. The shape goes by railway-oriented programming: two tracks and one switch. Each step allocates a wrapper, about 95 ns in Python.

Purity is the one idea here that no type can express: a signature saying datetime to timedelta tells you nothing about whether the function reads the clock. Ernest's remaining read it inside, so the answer depended on when you asked and no test could pin it down.

Hand the time in as now. The answer depends on the arguments alone, and a test can assert exactly thirty minutes to dispatch. An argument is cheap, and it shows in the signature.

9. Exercises

Exercise 1: make the typo impossible

Ernest's typo killed one branch of advance, and the program runs anyway. Turn the typo into a type error, and catch any status nobody handled while you're there.

Hint

Section 3 for the literal type. Section 5 for an exhaustive match with assert_never, or a switch with a never default.

Exercise 2: name the units and the flags

Two call sites disagree about the argument order, and both run. A row of bare booleans at a call site tells the reader nothing. Give the units types and the flags names.

Hint

Section 3 for the units. For the flags, use a frozen dataclass or an object type with named fields.

Exercise 3: parse the user once

The user record arrives as JSON and travels on as a loose dictionary, so every function re-checks the fields before using them. Write one parse at the edge, then delete the checks downstream.

Hint

Section 6. Write one parse function that returns a User or raises, call it in summary, and let the field types travel.

Exercise 4: separate the currencies

Each amount keeps its currency in a string beside it, so the program adds dollars to euros without a murmur. Move the currency into the types, and that sum stops compiling.

Hint

In Python, write one class per currency and a constrained type variable, [M: (Usd, Eur)]. In TypeScript, take a generic currency parameter and wrap the second argument in NoInfer, which pins the currency to the first amount.

Exercise 5: give each failure its reason

Three lookups can each fail, and all three collapse into one None. Rebuild the chain on Result.

Hint

Section 8. Write three small functions returning Result, and thread them with and_then.

10. The concepts

Every idea the page used, and the name people call it by. Read the third column if you read nothing else: most of this exists for the checker alone and disappears the moment your program starts.

ConceptWhat it meansScan or run timeSection
Gradual typingChecked and unchecked code in one program, with Any as the seam.Scan only2
AnyThe escape hatch: every value fits, and the checker stops looking.Neither; it turns the checker off2
Stringly typedStrings carry the meaning, where no checker can read it.Neither; the checker sees a string2
NarrowingThe checker follows a test and uses the narrower type inside the branch.Scan, and the test itself runs2
Type aliasA second name for the same type. It enforces nothing.Scan only; nothing survives3
NewType, brandA distinct type over the same representation.Scan only; the plain value remains3
Literal typeThe values that exist, written into the type itself.Scan only; plain strings at run time3
Units of measureQuantities that must never mix; their types keep them apart.Scan only3
Domain typeA type that states a fact about your problem.Scan; a class also exists at run time4
Primitive obsessionBuilding everything out of raw strings and numbers. Scott Wlaschin's name for it.Neither; the checker sees int and str4
Smart constructorThe one function that may build the type, holding its invariant.Both; the check runs, the type records it4
Structural and nominal typingAnything with the right shape fits, against only the declared name fits.Scan only4
ProtocolPython's opt-in structural typing: the methods a value must have, no inheritance.Scan only, unless marked runtime-checkable4
Tagged unionOne of several shapes, and the tag tells them apart. TypeScript calls it a discriminated union.Scan; the tag field is real data5
Illegal states unrepresentableReshape the data so the bad combination can't exist. Yaron Minsky's phrase.Scan only5
Exhaustiveness checkingEvery reader must handle every case, or the check fails.Scan, with a throw as the net beneath5
TypestateThe type carries the value's stage, so a step out of order is a type error. Rust named it.Scan only; the objects are ordinary5
ValidationCheck a value and pass the same value on. Nothing records that the check ran.Run time, and again at every reader6
ParsingRun the same check and return a different type, which every later function demands.Both; runs once, holds ever after6
Shotgun parsingEvery reader repeats the same check, and malformed input slips between them.Run time, repeatedly6
Parse, don't validateAlexis King's rule: convert at the border, then write functions on the data you wish you had.Both, by design6
Fake typeA type wide enough to hold whatever the source can hold. It spreads through every signature and rules nothing out.Scan only, and it holds nothing back6
Higher-order functionA function that takes or returns a function.Both; real values the checker types7
Signature preservationA wrapper that keeps the wrapped function's exact type.Scan only7
MonadA wrapper with a way in and a chaining step that returns the wrapper.Both; real values, checked chains8
Railway-oriented programmingScott Wlaschin's picture of a Result chain: two tracks, one switch.Both8
PuritySame arguments, same result, nothing else. Formally, referential transparency.Neither; a discipline the checker can't see8
Dependency injectionHand a function what it needs instead of letting it reach out.Both; a real argument with a type8
Boolean blindnessA row of bare booleans at a call site tells the reader nothing. Robert Harper's term.Neither; every bool fits9

11. The toolbox, compared

Which construct to reach for, and what it buys. Everything here needs Python 3.12 or TypeScript 5.4. Python checking remains gradual when a function has no annotations, and tsc trusts array indexing until you turn on noUncheckedIndexedAccess, which this page does.

Python

ConstructThe checker holdsAt run time, and what it costsReach for it whenSince
type aliasNothing; the same type under a new nameNothing exists, so nothing to payA long signature needs a short, honest name3.12 as the type statement
NewTypeThe label: Meters can't go where Pence belongsThe plain value, untouched. Each call through the constructor costs about 20 nsTwo same-shaped values must never swap3.5
LiteralA closed set of valuesPlain strings and numbers, so nothing to payA field or flag has a fixed menu3.8
Enum, StrEnumA closed set with a named member for each valueA real object. Reading a member costs about 12 ns, looking one up by value about 110The set must also exist at run time, as when parsing a CSV column3.4; StrEnum 3.11
@dataclass(frozen=True)The shape: field names and typesA real object, and frozen blocks mutation. About 135 ns to build, roughly three times a plain classData with named fields, which is most data3.7
NamedTupleThe shape, like a dataclassA tuple, so indexing and unpacking still work. About 100 ns to build, and lighter in memoryYou need tuple compatibility; otherwise a dataclass reads better3.6
TypedDictThe keys and value types of a dictionaryA plain dict, the cheapest of these at about 33 ns. Nothing enforces the shapeDictionaries you can't replace, like JSON already flowing through code3.8
Union of dataclasses + matchExhaustiveness: every reader handles every stateOrdinary objects; the match costs one tag comparisonA value is one of a few shapes, like the payment3.10; assert_never 3.11
Typestate classesThe order of operationsOrdinary objects, and every transition allocates oneSteps must happen in sequence, like the invoice3.5, with the first annotations
pydantic BaseModelThe shape, like a dataclassValidation: it rejects bad data at the door, at about 400 ns per object and 530 from JSONThe border, where data arrives as texta library

TypeScript

ConstructThe checker holdsAt run time, and what it costsReach for it whenSince
type aliasNothing; the same type under a new nameErased. The compiler emits nothing at allA long signature needs a short, honest name1.4
Brand (unique symbol)The label, like NewTypeErased, and the cast costs nothing; the plain value remainsTwo same-shaped values must never swap2.7
Literal unionA closed set of valuesErasedA field or flag has a fixed menu1.8
enumA closed set with named membersThe compiler emits a real object, and a member is a property readRarely: plain enums break under type-stripping runtimes, and a const object with a derived union covers the need1.0
Object type, interfaceThe shape: property names and typesErasedData with named fields, which is most data1.0
classThe shape and the methodsA real object; instanceof works, and construction costs about what an object literal doesIdentity or behaviour matters, as in the retry errors1.0
Discriminated union + neverExhaustiveness: every reader handles every stateErased, though the tag is a real property in every objectA value is one of a few shapes, like the payment2.0
Typestate object typesThe order of operationsErased, and every transition allocates one objectSteps must happen in sequence, like the invoice2.0
satisfies + as constConformance to a type without widening the literalErasedA value must match a shape and keep its exact keys4.9; as const 3.4
zod schemaThe inferred static typeValidation: about 46 ns per object, and 180 ns including JSON.parseThe border, where data arrives as texta library

Those figures come from one laptop, Python 3.14 and Node 26, and yours will differ. Remember the shape, not the numbers. Whatever the checker alone uses is free, because it vanishes before the program runs. Compile the TypeScript above and the alias, the brand, the literal union, the interface and the satisfies leave no trace; only the enum, the class and the values survive. You pay for what exists: an object to allocate, a member to look up, a value to validate. Freezing a dataclass roughly triples the cost of building one, and a validating model costs a hundred times a plain dictionary. Use both at a border, and rarely inside a loop.

Count the other costs too:

  • Data from the wild arrives dirty, and a model that holds only clean values can't load the record you need to repair.
  • A parser that stops at the first bad field can't collect every mistake at once.
  • Types named ValidThis and CheckedThat multiply.
  • Every invariant you add is a promise your fixtures must keep before they can build anything at all.

Where this goes next

Here's Ernest's file from section 1, rewritten. Try any of his five mistakes in it and press Type check.

The Rust side of this repository applies the same ideas: Making it unrepresentable. The examples behind this page live in tests/typesafety, and tests/typesafety.rs keeps them honest.

Turn your bugs into type errors.

Make illegal states unrepresentable.

Parse, don't validate.

How does this page work?

Edit any cell on this page and press a button. Run and lint execute in this browser tab; Python type checking opens Astral's official ty playground.

Run, Python. Pyodide is CPython compiled to WebAssembly. The first click loads it from a CDN. Each run hands your cell to the interpreter with fresh globals and shows what it printed.

Type check, Python. The button copies your edited cell and opens Astral's official ty playground; paste it into main.py to see the diagnostics. The repository gate checks the same files locally with ty==0.0.80 and Python 3.14. The captured checker words below come from that pinned CLI. Astral does not publish a browser package for embedding ty here, so the hosted playground may advance before this repository updates its pin.

TypeScript. tsc was always JavaScript, so one CDN script is the whole compiler. Diagnostics use the same strict flags as the repository's tests; Run transpiles and evaluates.

Lint. Ruff is Rust, compiled to WebAssembly. It is Ruff itself.

To edit this page. The prose is docs/type-safety.html. The code cells are files under tests/typesafety/, one Python and one TypeScript per example; each cell's Edit in GitHub button opens its own pair. Change one, then run UPDATE_SITE_DATA=1 cargo test --test typesafety to re-check it and regenerate the page's data. The buttons live in docs/type-safety.js.