A tutorial
Type safety, from primitives to monads
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_linetakes its four arguments in any order.- Prices are plain numbers, and some functions count in pence while others count in pounds.
advancecompares the status againstdarft, so no invoice leaves the draft state and no customer gets billed.billtakes its two ids in either order, as you just saw.load_bom_linepasses 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
ifthat 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;
| Noneand 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.
| Concept | What it means | Scan or run time | Section |
|---|---|---|---|
| Gradual typing | Checked and unchecked code in one program, with Any as the seam. | Scan only | 2 |
Any | The escape hatch: every value fits, and the checker stops looking. | Neither; it turns the checker off | 2 |
| Stringly typed | Strings carry the meaning, where no checker can read it. | Neither; the checker sees a string | 2 |
| Narrowing | The checker follows a test and uses the narrower type inside the branch. | Scan, and the test itself runs | 2 |
| Type alias | A second name for the same type. It enforces nothing. | Scan only; nothing survives | 3 |
NewType, brand | A distinct type over the same representation. | Scan only; the plain value remains | 3 |
| Literal type | The values that exist, written into the type itself. | Scan only; plain strings at run time | 3 |
| Units of measure | Quantities that must never mix; their types keep them apart. | Scan only | 3 |
| Domain type | A type that states a fact about your problem. | Scan; a class also exists at run time | 4 |
| Primitive obsession | Building everything out of raw strings and numbers. Scott Wlaschin's name for it. | Neither; the checker sees int and str | 4 |
| Smart constructor | The one function that may build the type, holding its invariant. | Both; the check runs, the type records it | 4 |
| Structural and nominal typing | Anything with the right shape fits, against only the declared name fits. | Scan only | 4 |
Protocol | Python's opt-in structural typing: the methods a value must have, no inheritance. | Scan only, unless marked runtime-checkable | 4 |
| Tagged union | One of several shapes, and the tag tells them apart. TypeScript calls it a discriminated union. | Scan; the tag field is real data | 5 |
| Illegal states unrepresentable | Reshape the data so the bad combination can't exist. Yaron Minsky's phrase. | Scan only | 5 |
| Exhaustiveness checking | Every reader must handle every case, or the check fails. | Scan, with a throw as the net beneath | 5 |
| Typestate | The type carries the value's stage, so a step out of order is a type error. Rust named it. | Scan only; the objects are ordinary | 5 |
| Validation | Check a value and pass the same value on. Nothing records that the check ran. | Run time, and again at every reader | 6 |
| Parsing | Run the same check and return a different type, which every later function demands. | Both; runs once, holds ever after | 6 |
| Shotgun parsing | Every reader repeats the same check, and malformed input slips between them. | Run time, repeatedly | 6 |
| Parse, don't validate | Alexis King's rule: convert at the border, then write functions on the data you wish you had. | Both, by design | 6 |
| Fake type | A 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 back | 6 |
| Higher-order function | A function that takes or returns a function. | Both; real values the checker types | 7 |
| Signature preservation | A wrapper that keeps the wrapped function's exact type. | Scan only | 7 |
| Monad | A wrapper with a way in and a chaining step that returns the wrapper. | Both; real values, checked chains | 8 |
| Railway-oriented programming | Scott Wlaschin's picture of a Result chain: two tracks, one switch. | Both | 8 |
| Purity | Same arguments, same result, nothing else. Formally, referential transparency. | Neither; a discipline the checker can't see | 8 |
| Dependency injection | Hand a function what it needs instead of letting it reach out. | Both; a real argument with a type | 8 |
| Boolean blindness | A row of bare booleans at a call site tells the reader nothing. Robert Harper's term. | Neither; every bool fits | 9 |
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
| Construct | The checker holds | At run time, and what it costs | Reach for it when | Since |
|---|---|---|---|---|
type alias | Nothing; the same type under a new name | Nothing exists, so nothing to pay | A long signature needs a short, honest name | 3.12 as the type statement |
NewType | The label: Meters can't go where Pence belongs | The plain value, untouched. Each call through the constructor costs about 20 ns | Two same-shaped values must never swap | 3.5 |
Literal | A closed set of values | Plain strings and numbers, so nothing to pay | A field or flag has a fixed menu | 3.8 |
Enum, StrEnum | A closed set with a named member for each value | A real object. Reading a member costs about 12 ns, looking one up by value about 110 | The set must also exist at run time, as when parsing a CSV column | 3.4; StrEnum 3.11 |
@dataclass(frozen=True) | The shape: field names and types | A real object, and frozen blocks mutation. About 135 ns to build, roughly three times a plain class | Data with named fields, which is most data | 3.7 |
NamedTuple | The shape, like a dataclass | A tuple, so indexing and unpacking still work. About 100 ns to build, and lighter in memory | You need tuple compatibility; otherwise a dataclass reads better | 3.6 |
TypedDict | The keys and value types of a dictionary | A plain dict, the cheapest of these at about 33 ns. Nothing enforces the shape | Dictionaries you can't replace, like JSON already flowing through code | 3.8 |
Union of dataclasses + match | Exhaustiveness: every reader handles every state | Ordinary objects; the match costs one tag comparison | A value is one of a few shapes, like the payment | 3.10; assert_never 3.11 |
| Typestate classes | The order of operations | Ordinary objects, and every transition allocates one | Steps must happen in sequence, like the invoice | 3.5, with the first annotations |
pydantic BaseModel | The shape, like a dataclass | Validation: it rejects bad data at the door, at about 400 ns per object and 530 from JSON | The border, where data arrives as text | a library |
TypeScript
| Construct | The checker holds | At run time, and what it costs | Reach for it when | Since |
|---|---|---|---|---|
type alias | Nothing; the same type under a new name | Erased. The compiler emits nothing at all | A long signature needs a short, honest name | 1.4 |
Brand (unique symbol) | The label, like NewType | Erased, and the cast costs nothing; the plain value remains | Two same-shaped values must never swap | 2.7 |
| Literal union | A closed set of values | Erased | A field or flag has a fixed menu | 1.8 |
enum | A closed set with named members | The compiler emits a real object, and a member is a property read | Rarely: plain enums break under type-stripping runtimes, and a const object with a derived union covers the need | 1.0 |
Object type, interface | The shape: property names and types | Erased | Data with named fields, which is most data | 1.0 |
class | The shape and the methods | A real object; instanceof works, and construction costs about what an object literal does | Identity or behaviour matters, as in the retry errors | 1.0 |
Discriminated union + never | Exhaustiveness: every reader handles every state | Erased, though the tag is a real property in every object | A value is one of a few shapes, like the payment | 2.0 |
| Typestate object types | The order of operations | Erased, and every transition allocates one object | Steps must happen in sequence, like the invoice | 2.0 |
satisfies + as const | Conformance to a type without widening the literal | Erased | A value must match a shape and keep its exact keys | 4.9; as const 3.4 |
| zod schema | The inferred static type | Validation: about 46 ns per object, and 180 ns including JSON.parse | The border, where data arrives as text | a 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
ValidThisandCheckedThatmultiply. - 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.