module 19 · why the rules are theorems

What types cannot do

The 30-second version. Chapters 01–04 built the type system into a theorem prover: soundness, memory safety, liveness, machine-checked; this chapter is the receipt. Four things no type system can give you: termination (halting problem — total languages dodge it by rejecting legal loops), spec fidelity (types prove syntactic facts; "this sorts" is semantics, paid for with tests or module 21), the world (I/O and time cannot be caged by a pure type — Haskell's IO is an honest surrender), and escape hatches (any unsafe/any is a membrane — the same kernel-membrane-bulk shape as an OS kernel and a browser). Then the two grand trades — soundness vs precision, and static vs dynamic as when you pay for the proof — ending in a 6-question checklist for any language.

The first impossibility: no checker admits exactly the terminating programs

Chapter 01's diagonalization sets a ceiling every type system lives under. Suppose a checker accepted exactly the terminating programs. Feed it the four-line Collatz loop below: it must either say "terminates" — thereby proving the Collatz conjecture, open since the 1930s — or refuse, rejecting a loop everyone has run a billion times. No third answer: by Rice's theorem, every non-trivial semantic property of programs is undecidable. Real checkers pick a side, and both sides cost:

The second: types prove syntax, not specs — and even data models fight

A type claims shape: this value is drawn from that set, and soundness says evaluation stays inside the promised shapes. Every interesting bug lives one level up, in semantics: "this function sorts," "this dose calculator never returns a lethal value," "this loop eventually releases the lock" — specifications, which no decidable checker can verify (the same Rice theorem). The gap is filled by tests (weaker, finite proofs about sample inputs), formal verification (module 21's machines, at the price of proof scripts), or nothing — the default. The smallest instance: int addition typechecks, yet x + 1 < x can be true — a value fact, invisible to a shape checker. Milner's "well-typed programs cannot go wrong" means cannot get stuck — overflow, wrong answer, wrong dose: all still possible.

Even at the data-model level, guarantees fight: the expression problem is the minimal proof. Extend a type along two axes — new cases (Triangle joins Circle | Rect) and new functions (perimeter() joins area()) — under two designs:

                sum types (Rust enum /       OO interfaces
                TS discriminated union)      (a class per case)
+---------------+---------------------------+---------------------------+
| add a CASE    | touch EVERY function:     | free: new subclass,       |
| (variant)     | every exhaustive switch   |   zero existing code      |
|               | must handle it            |   edited                  |
+---------------+---------------------------+---------------------------+
| add a         | free: just write the      | touch EVERY class: the    |
| FUNCTION      |   new function            |   method joins every      |
|               |                           |   implementing class      |
+---------------+---------------------------+---------------------------+

Neither column is better; they place the friction on opposite axes. Wadler named the tension in 1998; the visitor pattern is the OO compromise — cases become one method per case on a visitor interface, so new functions (visitors) are free while new cases still edit every visitor. The visitor moves the seam, never removes it: impossibility 2 in miniature — both axes cheap is not on the menu.

The third: the world — effects a pure type cannot cage

A program is a closed box of terms; the world is not. read(), now(), another process return different values for identical expressions; no type system can constrain what the kernel returns, and the machine beneath even disagrees with itself. Types stop at the process boundary — Impossibility 3.

Haskell's answer is an honest surrender, not a solution. IO brands a computation as "this is an effect," and the only thing the type system knows about branded values is how to sequence them:

-- the entire effect surface of the program is in the type
main :: IO ()
main = do
  putStrLn "name?"          -- effect #1
  name <- getLine           -- effect #2, must happen after #1
  putStrLn ("hi, " ++ name) -- effect #3; () = no result value

main is not a printed string; it is a value describing an effectful computation, and do/>>= is the only law: run in order. The type system never inspects the effect — it cannot prove the file exists or the writes are ordered. You get tracking — which code can touch the world is a typed fact — instead of knowledge. The frontier tries for more: effect rows (Koka) let a function's type say <io>, <div>, <throw> and compose rows across calls. Research-frontier: none of module 01's deployed languages has it.

The fourth: escape hatches — the TCB shape, drawn three times

Any sound system large enough to be useful grows a hole by design: Rust's unsafe, TypeScript's any, Java's casts, Python's ctypes. The theorem changes shape instead of dying: soundness becomes "the kernel of provably-safe code plus the membrane of audited code upholds the invariants" — the shape of every trustworthy system, seen twice already in this program:

    OS kernel                 browser                  Rust project
+--------------------+  +--------------------+  +--------------------+
| ring 0: kernel     |  | broker (the        |  | unsafe blocks      |
| small, privileged, |  |   browser process: |  | small, privileged, |
| AUDITED, owns all  |  |   owns screen,     |  | AUDITED, owns raw  |
|   hardware         |  |   disk, sockets)   |  |   memory           |
+--------------------+  +--------------------+  +--------------------+
| membrane: the      |  | membrane: Mojo IPC |  | membrane: the safe |
|   syscall table    |  |   + seccomp filter |  |   API (Vec, Mutex, |
|   (the only door)  |  |   (the only door)  |  |   send — the door) |
+--------------------+  +--------------------+  +--------------------+
| ring 3: processes  |  | renderers:         |  | safe Rust: the     |
|   untrusted bulk   |  |   untrusted DOM/JS |  |   95% the checker  |
|   (the 99%)        |  |   bulk (the 99%)   |  |   proves for free  |
+--------------------+  +--------------------+  +--------------------+

Same three layers, three times: a small trusted kernel, a narrow membrane as the only door to its powers, and a bulk that is never trusted — only checked. This is the program's thesis, stated one last time: soundness is never a property of the whole program; it is a property of the kernel plus the membrane. The corollary: the audit surface is measurable — count the kernel, review the membrane. When a renderer escapes or an unsafe impl Send lies, the bug is never in the bulk: the postmortem goes to the kernel or the membrane.

Trade one: soundness or precision — pick your false friend

Soundness (a well-typed program really is safe) and precision (the checker accepts most reasonable programs) trade off, and real languages have chosen every corner:

                         sound (guarantees hold)
              +--------------------------------------+
              |   Rust / OCaml / SML                 |   Haskell: sound,    |
   precise    |   borrow checker REJECTS legal       |   but the price is   |
   (accepts   |   programs: the false-positive tax   |   effect limits and  |
    a lot)    |   (fighting NLL, variance...)        |   IO puritanism      |
              +--------------------------------------+
              |   TS / Flow: precise, DELIBERATELY   |   (no major taker    |
   permissive |   unsound (any, assertions,          |    of this corner)   |
              |   covariant arrays)                  |                      |
              |   Java 1996-2004: casts + covariant  |                      |
              |   arrays = unsound BY ESCAPE         |                      |
              +--------------------------------------+
                         unsound (holes by design)

Read the corners as invoices. Rust/OCaml pay in rejected programs — every borrow-checker fight and appeasing clone() is the false-positive tax. TypeScript bought the opposite corner deliberately: any, assertions, and covariant arrays leak unsoundness for near-zero friction on a 10-million-line JavaScript codebase. Java's cast era: ClassCastException is a type system arriving late. Haskell is sound but effects-limited — purity is real, monadic plumbing is the price. No corner is free; a language claiming sound and precise and permissive is lying about one of them.

Trade two: static vs dynamic is a proof-schedule choice

The holy war dissolves under the module's lens. The same proofs — "this is an int," "this object is live," "this borrow is unique" — can be scheduled at compile time or at runtime. The tag word on every Python object (module 01 chapter 06's certificate: 16 B of refcount + type ptr per value), the GC pause (chapter 04's bill: ~4 s on a 2 GB heap at ~500 MB/s marking), and the borrow error (a compile error today instead of a use-after-free in month nine) are one proof at three schedules:

+---------------------+------------------------+------------------------+
|                     | static schedule        | dynamic schedule       |
+---------------------+------------------------+------------------------+
| unit cost           | seconds-minutes, once  | ns/op (tag check) to   |
|                     | per build              | seconds (GC pause)     |
| paths paid for      | ALL paths, including   | only paths actually    |
|                     | never-executed ones    | executed               |
| when the bill       | before you run         | mid-request,           |
| arrives             | (compile latency)      | mid-loop, mid-pause    |
| failure mode        | rejected build (cheap) | TypeError / pause /    |
|                     |                        | crash (expensive)      |
+---------------------+------------------------+------------------------+

The arithmetic that ends the argument: a proof costing 10 s once beats 12 ns per execution after 10 s / 12 ns ≈ 8 × 10^8 ≈ 10^9 executions. A service at 3,000 calls/s crosses that in ~4 days and banks margin forever — static amortizes. A script run 20 times pays 10 s to save 480 ns — dynamic wins by eight orders of magnitude, and the developer inner loop is the scripts regime — why the same person can rightly write Python at 11 a.m. and Rust at 3 p.m. Never philosophy; when you pay.

What survives is a 6-question checklist for any language, applied to the four you know best:

questionRustTypeScriptGoPython
proves at compile?memory + thread safety (mod unsafe)shape/nullability, unsoundlytypes; races only with -racenothing (syntax only)
proves at runtime?bounds/overflow panics (debug)JS tag checksnil-map, bounds, GCtag check on EVERY op
tag cost?none (erased; enums pay 1 byte by choice)none (erased) — JS objects carry shape mapsinterfaces carry itabs16 B header/obj, ints boxed 28 B
escape hatch blast radius?unsafe: whole-crate soundnessany: bisection — spreads silentlyinterface{}, unsafectypes: the process
GC proves, when?nothing (ownership proved at compile)V8 tracing, pauses = billconcurrent GC, ~sub-msrefcount now + cycles later
TCB?unsafe blocks + std + rustcchecker + everything behind anyruntime + reflect + unsafeCPython + every C extension

What insiders know

See it yourself

1. Termination on an open problem (4 lines, any machine).

def collatz(n):
    while n != 1:
        n = 3*n + 1 if n % 2 else n // 2
    return n
$ python3 -c 'from ch05 import collatz; print(collatz(27))'          # 111 steps
$ python3 -c 'from ch05 import collatz; print(collatz(670617279))'   # 986 steps

Every input ever tried halts (verified to 2^68 ≈ 3×10^20); nobody can prove all do. Any tool deciding termination for this file would decide Collatz. Watch it run; that is the gap.

2. Spec fidelity: types pass, the property fails.

# pip install hypothesis   (Python 3.8+)
from hypothesis import given, strategies as st

def sort(xs: list[int]) -> list[int]:     # the annotation is TRUE
    return sorted(set(xs))                # ...and the spec is FALSE

@given(st.lists(st.integers()))
def test_permutation(xs):
    assert sorted(xs) == sort(xs)         # weaker proof than a theorem,
                                          # stronger than any unit test
test_permutation()

Hypothesis 6.x falsifies in ~31 examples and shrinks to the boundary:

Falsifying example: test_permutation(
    xs=[0, 0],      # the duplicate boundary: sorted(set()) drops it
)

The annotation list[int] -> list[int] is honest and useless: "permutation" is semantics — no type system catches this.

3. The expression problem in 2 × 30 lines (TypeScript playground). Sum-type version:

type Shape = { kind: "circle"; r: number } | { kind: "rect"; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "rect":   return s.w * s.h;
    default: { const _exhaustive: never = s; return _exhaustive; }
  }
}
function perimeter(s: Shape): number { /* same switch shape */ return 0; }

Add { kind: "tri"; a: number } to the union: the compiler instantly flags area() and perimeter() at each never check — every function touched (top-left cell). Now write describe(): string — zero existing lines edited. Then build the OO mirror (abstract class Shape, Circle/Rect subclasses) and invert: Triangle extends Shape edits nothing; describe() edits every class — you have felt both cells of the 2×2.

4. The escape-hatch census (any real tree, one command).

$ cd /usr/lib/python3.13/asyncio
$ rg -c "# *type: *ignore" -g '*.py' --no-filename | paste -sd+ | bc   # 3
$ rg -c "except *:"      -g '*.py' --no-filename | paste -sd+ | bc   # 15
$ cat $(rg --files -g '*.py') | wc -l                                  # 14754

CPython 3.13's asyncio: 14,754 lines, an audit surface of 18 sites (3 # type: ignore, 15 bare except: in 7 files) — a membrane ratio of 0.12%. That number is the review budget. The same census on Rust: rg -c "unsafe" -g '*.rs'; on TypeScript: rg -w "any" -g '*.{ts,tsx}'. Run it on your dependency tree before you trust it.

5. The proof-schedule experiment: 10^7 calls, two languages.

$ python3 timing.py          # loop of 10**7 calls, time.perf_counter
duck-typed method call :  110 ns/call  total 1.10 s
plain function call    :   69 ns/call  total 0.69 s
$ gcc -O2 static.c && ./staticc     # noinline static void step(void)
static C call          :  2.1 ns/call  total 0.02 s

(One core of the build VM; CPython 3.12, gcc 14.2.) The naive read — "dynamic typing is 50× slower" — is wrong; the confound: ~108 of those 110 ns is interpreter tax (bytecode dispatch, frame setup), not tag-checking. The tag tax visible inside Python: the ~40 ns between bound-method and plain calls — and 3.12's specializing interpreter already discounts it; a JIT with inline caches (V8) amortizes dispatch to single-digit ns. The chapter's honest conclusion: dynamic costs per execution, forever; static costs once, for all paths — 10^7 × ~100 ns is a full second, every run.

How to use this knowledge

Connections

Quiz

  1. Concept. The vocabulary ladder claims type soundness ⊆ memory safety. Give the containment direction in words, one language occupying each of the two innermost rings, and the runtime event that proves the outer ring can hold when the inner one fails.
  1. Concept. A colleague says "static vs dynamic typing is a philosophical preference." In one sentence each, recast the tag word, the GC pause, and a borrow-checker error as the same thing at three different schedules.
  1. Predict/derive. A static proof costs 10 s once per release; the equivalent dynamic proof costs 12 ns per execution. Derive the break-even execution count, then rule on (a) a CLI script run daily for 5 years and (b) a service at 3,000 calls/s: which schedule wins, by roughly how much?
  1. Predict. In the sum-type shape code (union of circle | rect, exhaustive switches with never checks in area() and perimeter()), predict exactly what the compiler reports when { kind: "tri" ... } joins the union — which functions, which line, what error. Then predict the same two edits for the OO version.
  1. Derive. A Python list holds 10^6 distinct integers (CPython 3.12, 64-bit). Compute total bytes and the certificate fraction (object header + list pointer) versus payload, given sys.getsizeof(1) == 28 and 8-byte list slots. Which schedule is the certificate paying for?
  1. Diagnose. A Rust service segfaults; the stack ends in Vec::push reached through unsafe impl Send for RawChannel in a dependency. Safe code called the safe API. Where is the bug, why is "Rust is memory-safe" not falsified, and which two tools does the audit use?
  1. Diagnose. A TypeScript codebase crashes at runtime: TypeError: Cannot read properties of undefined (reading 'price') on items[0].price, though items is declared Item[]. The value crossed JSON.parse(raw) as any at an API boundary. Explain the bisection: what did the checker check downstream, and what is the minimal boundary fix and why does it work?
  1. Cross-chapter. A Rust thread sets a flag with AtomicBool::store(flag, Ordering::Relaxed); another thread, after seeing the flag, reads the buffer published before it. The borrow checker approved the code; the second thread sees stale data. Connect to module 01 chapter 20: which impossibility is this, what exactly did the type system prove (and not prove), and where did the proof obligation go?
Answers
  1. Type soundness implies memory safety; the reverse fails. Inner ring (type-sound): Rust or SML — well-typed programs never commit a type-confused access, which entails no OOB, no UAF, no wild pointers. Outer ring (memory-safe, statically unsound): Java-with-casts or modern TypeScript — the runtime event proving the outer ring held while the inner failed: ClassCastException, a type error at runtime with no memory corruption.
  1. One proof, three schedules. The tag word is the certificate carried per value, checked on every operation (paid per execution); the GC pause is the liveness proof's bill arriving mid-run (paid per collection, ~seconds); the borrow error is the aliasing proof delivered at compile time (paid once, for all paths). Static vs dynamic is the schedule of one obligation, not a philosophy.
  1. Break-even: 10 s / 12 ns ≈ 8.3 × 10^8 ≈ 10^9 executions. (a) The script runs 5 × 365 ≈ 1,800 times: dynamic pays 1,800 × 12 ns ≈ 22 µs against static's 10 s — dynamic wins by ~500,000×. (b) The service does 3,000 × 86,400 ≈ 2.6 × 10^8 calls/day, crossing 10^9 in ~4 days; over 2 years (~1.9 × 10^11 calls) dynamic would pay ≈ 2,300 s ≈ 38 min of checking versus 10 s once — static wins by ~200× and covers all paths, executed or not.
  1. Sum types: the union edit is accepted, but every exhaustive switch fails its never check — both area() and perimeter() error at the const _exhaustive: never = s line, of the form Type '{ kind: "tri"; a: number; }' is not assignable to type 'never' (TS2322). Two existing functions touched: the top-left cell. describe() is a new function: zero existing lines change. OO: Triangle extends Shape overrides compile with zero edits to existing classes; the new describe() must be added to the base class and implemented in every subclass — every case edited: the bottom-right cell.
  1. Total: 10^6 × 28 B (int objects) + 10^6 × 8 B (list slots) ≈ 36 MB (−5..256 are cached singletons; "distinct" avoids the discount). Payload: one 30-bit digit = 4 B of each 28 B object, so payload ≈ 4 MB. Certificate = 24 B header (refcount 8 + type ptr 8 + size 8) + 8 B pointer = 32 B per element: 32/36 ≈ 89% certificate, 11% payload. It pays for the dynamic schedule: every operation re-proves "this is an int" from the header, on every use, every execution.
  1. The bug is in the membrane, not the safe bulk. unsafe impl Send asserts "RawChannel has no thread-unsafe interior" — a proof obligation the compiler trusts. If the buffer is mutated from two threads, the impl lied and the crash is the deferred bill. "Rust is memory-safe" survives because the theorem was always conditional: soundness = every unsafe block upholds its invariants. Tools: Miri (chapter 03's operational model) and this chapter's unsafe census — audit the ~40-line unsafe kernel, not the 10,000 safe callers.
  1. Bisection: as any is assignable both directions, so the checker stopped at the boundary and every downstream function typechecked against a fiction — items: Item[] was never contradicted because nothing checked the JSON's actual shape. Minimal fix: const raw: unknown = JSON.parse(rawText) plus a narrowing guard before use (typeof, Array.isArray). unknown accepts anything inward but pays before use, settling the proof debt once, at the membrane — downstream guarantees become earned, not asserted.
  1. Impossibility 3 — the world. Rust's type system proved data-race freedom — no two &mut to the same memory — so the sharing itself was legal. It did not — and cannot — prove ordering: with Relaxed, module 01 chapter 20's hardware memory model lets the flag store become visible before the buffer stores it guards. The proof obligation moved into happens-before reasoning (Acquire/Release — module 09's territory) or a verifier (module 21). The types caged aliasing; the memory model is the proof the machine refuses to give you.