◈ python mapVol 2 · Ch 23/30
Volume 2 Python, from the metal up · chapter 23

23The type system — runtime types vs static hints

In Chapter 22 we pinned the environment down — venv, pip, a lockfile — so the code on your machine is the code that runs everywhere else. In this one we turn on the code itself, and go after a small lie most of us half-believe. You wrote age: int = 5, asked type(age), got int back. Something in you concluded the : int is what made it an int. It didn't. So here's the plan. We watch Python run two type systems at once. One is a runtime system, where every object wears its type in its own header at a real RAM address. The other is a static system, where hints the interpreter never reads get studied by a separate program. That program reasons about your code without running a single line. And the whole way through we keep asking the one question that dissolves every confusion about types — when you write : int, what actually reads it, and why is that never the same thing type() reads? By the end we can see the seam plainly. Two mechanisms wear the same word, stored in two different places, with no wire connecting them. Once you feel that gap, annotations, Optional, generics, and protocols all stop looking like magic.

iolinked · chapter 23 — the checkpoints7 steps
$ sections covered in The type system — runtime types vs static hints
01Two type systems, coexisting
02A checker reasons without running code
03Narrowing: how control flow teaches the checker
04Optional and Union: a name with several types
05Generics: types parameterized by types
06Protocols: structural typing the checker can verify
07Hint the seams, infer the rest

01Two type systems, coexisting

Let's start at the prompt, because the whole chapter cracks open in three lines. Type age: int = 5. Then — no error, no complaint, nothing — type age = 'oops'. Now ask type(age), and watch what it says: str. Sit with that for a second. You annotated the name as int, you stored a string in it, and Python shrugged. If the hint had actually made age an int, that second line would have been a crime — a type error, a refusal, something. It was not even noticed. So the : int did not make anything anything, which leaves us holding a sharp question. Plainly, type(age) is reading some other thing than the : int you wrote, so what is it reading? That gap is the seam, and we are going to walk straight into it.

Go back to Vol 1's opening truth. Every value in Python is a PyObject living at some address on the heap, and every object begins with a header. The very first useful field in that header is ob_type. It's a pointer to the object's type object, a PyTypeObject that itself lives somewhere in memory. When you call type(x), CPython does almost nothing. It runs the macro Py_TYPE(x), which is a single dereference of that header field. Not a search, not a comparison, not a lookup by name. It's an O(1) read of a pointer that travels with the object. So the type is a physical property of the thing on the heap. The name age is never consulted. The runtime type belongs to the object 5, or a line later to the object 'oops', not to the label you tied around it.

So where did the : int go? It compiled to something entirely separate, and the compiler will show you. An annotated assignment like age: int = 5 is really two unrelated acts glued into one line. The first is the ordinary assignment age = 5, which stores the value. The second is the annotation int, which at module or class scope gets filed as a key/value pair into a real, inspectable dictionary named __annotations__. Watch the bytecode prove they never touch:

two_stores.pypython
import dis

def annotated():
    x: int = 5          # with the hint
    return x

def plain():
    x = 5               # without it
    return x

dis.dis(annotated)
#   LOAD_CONST   5
#   STORE_FAST   x        <- stores the value, no type check
#   LOAD_FAST    x
#   RETURN_VALUE
dis.dis(plain)
#   LOAD_CONST   5         identical. byte for byte.
#   STORE_FAST   x
#   LOAD_FAST    x
#   RETURN_VALUE

Byte for byte identical. Inside a function the annotation int is not even evaluated. PEP 526 says local annotations are recorded by the compiler as metadata and then thrown away. The frame never reads them, the name int is never looked up, and no __annotations__ entry is created for a function local at all. At module scope there's exactly one extra instruction, SETUP_ANNOTATIONS. It lazily creates the module's __annotations__ dict, so the compiler can drop the card 'age': int into it. That card is inert side-data. You can print it, mutate it, even del a key out of it. It is a filing cabinet, not a law. Function signatures work the same way. def f(n: int) -> str stores its hints in f.__annotations__, a plain dict hanging off the function object that f never once reads while it runs.

SYNTAX · the annotation — name: type, and the arrow for what comes backfour places a hint can sit, one grammar, and a runtime that checks not one of them
name: TYPE = value -- annotated assignment: the hint, then a NORMAL store name: TYPE -- a DECLARATION. nothing is stored. the name stays unbound. def f(a: TYPE, -- a parameter's hint goes after its colon b: TYPE = default -- hint FIRST, then the default. never the other way. ) -> TYPE: -- the ARROW: what f hands back to its caller ... class C: field: TYPE -- a class-level declaration; @dataclass reads these other: TYPE = value -- and the places a hint may NOT go: for x: int in items -- SyntaxError lambda x: int: x -- SyntaxError: illegal target for annotation with open(p) as f: TYPE -- SyntaxError
the colonReads I intend this name to hold this type. It is a note for a reader and a checker, never an order to CPython.
= valueOrdinary assignment, completely unchanged. Strip the hint and the bytecode is identical, exactly as the dis pair above showed.
name: TYPEA declaration with no store. The name stays unbound, and reading it raises NameError like any other missing name.
-> TYPEThe return hint, and the one that pays most. Every caller learns what comes back without opening the body.
the defaultb: int = 3 puts the type before the default value. Swap them and Python refuses to compile the line at all.
__annotations__Where the hints land: a plain dict on the module, the function, or the class. That is the entire runtime effect.
nothing enforcesNo check fires when you store, and none fires when you call. A wrong hint is a wrong comment until a checker reads it.
you type
# annotate_basics.py -- the four places a hint can go, and what it costs
x: int = 0                          # 1. annotated assignment: hint AND value
y: str                              # 2. declaration only: NOTHING is stored


def is_long(title: str, limit: int = 12) -> bool:   # 3. params, default, arrow
    return len(title) > limit


class Track:                        # 4. class-level field declarations
    title: str
    seconds: int = 0


x = "not an int"                    # the hint said int. nobody checks.
print("x is now       :", type(x).__name__, repr(x))
print("is_long('Levels'):", is_long("Levels"))
print("is_long('Blinding Lights'):", is_long("Blinding Lights"))
print("module hints   :", __annotations__)
print("is_long hints  :", is_long.__annotations__)
print("Track hints    :", Track.__annotations__)
try:
    print(y)
except NameError as exc:
    print("reading y      : NameError:", exc)


# ---------- the terminal ----------
$ python annotate_basics.py
$ python -m mypy annotate_basics.py
you see
$ python annotate_basics.py
x is now       : str 'not an int'
is_long('Levels'): False
is_long('Blinding Lights'): True
module hints   : {'x': <class 'int'>, 'y': <class 'str'>}
is_long hints  : {'title': <class 'str'>, 'limit': <class 'int'>,
                  'return': <class 'bool'>}
Track hints    : {'title': <class 'str'>, 'seconds': <class 'int'>}
reading y      : NameError: name 'y' is not defined

$ python -m mypy annotate_basics.py
annotate_basics.py:15: error: Incompatible types in assignment
    (expression has type "str", variable has type "int")  [assignment]
Found 1 error in 1 file (checked 1 source file)
where beginners trip
  • Line 15 stores a str into a name annotated int, and Python exits 0. The hint never had a vote.
  • A hint is not a cast. age: int = input("age? ") leaves a str in age, and age * 2 will repeat the text.
  • y: str binds nothing at all. The next line that reads y raises NameError, which surprises almost everyone once.
  • Write the default before the type — def f(a = 3: int) — and you get a SyntaxError at compile time, not a warning.
  • Annotations on locals are recorded by the compiler and thrown away. def g(): z: int = 1 leaves g.__annotations__ empty.
  • Add from __future__ import annotations and every hint becomes a string: {'seconds': 'int'}. Use typing.get_type_hints(f) to resolve them back.

There it is, laid bare. The runtime type is a live pointer in the object's header, read by type(). The static hint is a string-or-object filed in a side dictionary. At runtime it is read by nobody, and before runtime it is read by a separate checker. Two systems, two storage locations, and — this is the whole chapter in one sentence — zero interaction between them. The name type is doing double duty across a chasm.

TYPE THIS · save it, then run it twicehints_lie.py — the same nine lines read by two machines, and only one of them objects
"""hints_lie.py -- one file, two readers: the interpreter and the checker."""


def as_clock(seconds: int) -> str:
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


total: int = "245"                     # the lie: a str where int was promised

print("the hint      :", __annotations__["total"])
print("what is stored:", type(total).__name__, repr(total))
print("as_clock hints:", as_clock.__annotations__)
print("honest call   :", as_clock(245))
print("now the lie   :", as_clock(total))


# ---------- the terminal, twice ----------
$ python hints_lie.py
$ python -m mypy hints_lie.py
$ python hints_lie.py
the hint      : <class 'int'>
what is stored: str '245'
as_clock hints: {'seconds': <class 'int'>, 'return': <class 'str'>}
honest call   : 4:05
Traceback (most recent call last):
  File "C:\tmp\ch23lang\hints_lie.py", line 15, in <module>
    print("now the lie   :", as_clock(total))
                             ^^^^^^^^^^^^^^^
  File "C:\tmp\ch23lang\hints_lie.py", line 5, in as_clock
    minutes, secs = divmod(seconds, 60)
                    ^^^^^^^^^^^^^^^^^^^
TypeError: unsupported operand type(s) for divmod(): 'str' and 'int'

$ python -m mypy hints_lie.py
hints_lie.py:9: error: Incompatible types in assignment (expression has
    type "str", variable has type "int")  [assignment]
Found 1 error in 1 file (checked 1 source file)
Save it, run it, then run mypy on the very same file and read the two verdicts side by side. Python printed the hint, stored a str in a name promised as int, and carried on. The lie survived four whole lines of output. It became a crash only when divmod finally tried arithmetic on '245', deep inside as_clock and far from where you told it. That distance is the standing cost of a dynamic type system: the error surfaces where the value is used, never where it was wrong. Now read the mypy line. It names line 9 — the assignment itself, the exact character where the promise broke. The traceback showed you the deathbed; the checker showed you the birthplace. Then notice what mypy did not say. It never flagged as_clock(total) on line 15, because it takes your declaration at its word: you said total is int, so int is what it believes from there on. The checker holds you to your own promise rather than to the value you actually stored, and that is the honest shape of gradual typing. Two things to try. Change line 9 to total: str = "245" and re-run mypy — the assignment goes green and line 15 lights up instead, which tells you the two errors were always the same disagreement. Then delete the hint on line 9 entirely and run mypy again. Silence, on both lines. No promise, no verdict — and the program still crashes exactly as before.
RUNTIME WORLD · the heapSTATIC WORLD · annotationsnameageob_type → intvalue5__annotations__'age' : intno wire connects thesetype(age)reads the LEFT header, at runtimemypyreads the RIGHT card, before it runs
Fig — The value 5 lives in the heap; the annotation age:int lives in __annotations__. type(age) reads the object header, a checker reads the annotation — no wire links them.
THE ONE IDEA TO CARRY FORWARD
The word type names two different machines. One is a pointer welded into every object on the heap — that's what type() reads and what actually governs what happens when code runs. The other is a note filed in __annotations__ — read only by a checker, and only before the code runs. A wrong note never crashes; a right note never helps at runtime. They are not connected, and the rest of this chapter is what you can build once you stop expecting them to be.
Wait — if the annotation at module scope is really evaluated, then a hint with a typo can actually crash — x: itn = 5 raises NameError: name 'itn' is not defined, because Python tries to look up itn to file it in the cabinet. But x: int = 5 where you store a string never complains, because the value and the annotation are checked by nobody in common. The annotation can fail to be looked up; it can never fail to match. Add from __future__ import annotations (PEP 563) and even the typo goes quiet — annotations are then stored as the raw string 'itn' and never evaluated at all.

So the hint is a note nobody reads at runtime. But somebody does read it — a separate program that never runs your code and yet catches bugs your tests never reach. Next: how a checker reasons about every possible run at once. →

02A checker reasons without running code

You have run mypy and watched it print errors, and it probably felt like a spellchecker — a linter grepping your text for suspicious shapes. It is doing something far stronger than that, and far stranger. A type checker never executes your program. It reads the source the way you would if you traced it in your head. From that reading it constructs a proof about every possible run, including the runs your tests will never trigger. Testing samples reality. Checking reasons about all of it. To trust the tool you have to see the machine, so let's build its shape.

Recall from Vol 1 how the interpreter itself works. It keeps a namespace, a map from each name in scope to the object it currently points at. It walks your statements one at a time, mutating that map as it goes. A checker is the same walk with one substitution. It parses your source into an AST — the tree of statements and expressions — and walks it in control-flow order. As it walks it maintains a typing environment: a map from each in-scope name to its currently-inferred type, not its value. It binds name → type where the interpreter binds name → object, and it never runs anything. This technique has a name: abstract interpretation. Instead of computing with concrete values, the checker computes with the lattice of types. Those are the abstract stand-ins for whole sets of values at once.

Watch it thread a type through an expression it has never evaluated:

threaded.pypython
x = 5            # literal 5 has type int  -> env: x: int
y = x + 1.0      # int.__add__(float)? typeshed says the result is float
                 #                          -> env: y: float
z = str(y)       # str(float) returns str    -> env: z: str
z + 1            # str.__add__(int)? no such overload -> ERROR, before it runs

At no point did 5, 1.0, or str(y) actually get computed. The checker inferred x: int from the literal. Then it asked a question it cannot answer by introspection — what does int + float return? — and looked the answer up in typeshed. Typeshed is the corpus of .pyi stub files. These are signatures-only declarations for the entire standard library and every C-implemented builtin, which have no Python source for the checker to read. The stub for int.__add__ declares that adding a float yields a float, so the environment records y: float. That fact came purely from reading two files, never from running one line. The last line is a proof of a contradiction. str has no __add__ taking an int, so the checker reports an error for code the interpreter never reached.

This is why checking is categorically stronger than testing. A dynamic test only ever observes the exact path it executed. A branch guarded by if rare_condition: that your test suite never enters is invisible to every test you will ever write. The checker reads that branch anyway. It reasons about the program text, which contains all branches, rather than one execution, which contains one path. So a green test suite says "the runs I sampled worked." A green checker says "no run can do the thing I forbid." Those are different promises, and the second is a proof.

Interactivedrag the argument count
one function · N int arguments — how many distinct runs could it ever face? def f(a, b, c: int) POSSIBLE RUNS · DISTINCT INPUT TUPLES 6.3 × 10⁵⁷ BRUTE-FORCE TEST ALL · 1 BILLION / SEC 1.4 × 10³¹ × the age of the universe ≈ every atom in the Sun (10⁵⁷) A TEST SUITE COULD EVER SAMPLE… ≈ 0% — a test suite could ever reach 1 run in 1.4 × 10³¹ ↑ all testing could ever reach log scale The checker never runs a line: it folds all 6.3 × 10⁵⁷ possible runs into one proof — in ~50 ms.
3 args
Slide from one argument to six. A test suite can only ever visit runs it actually executes — a stub that never grows past a few thousand cases. The type checker never runs your code at all: it reasons over the entire input domain at once, which is exactly why it catches the bug waiting on the branch your tests will never reach.
Fig — Testing is sampling; checking is proof. Even if each int held only the 2⁶⁴ values a 64-bit word can, a 3-argument function has ~6.3×10⁵⁷ possible runs — more than atoms in the Sun. A test suite samples a handful; the checker proves the type property over all of them in one pass, before the code runs once.

Be honest about the cost of that strength, because it explains the checker's most annoying moments. Its model is an over-approximation. It must assume every branch could run, since it can't know your runtime invariants. So it will flag a possible None dereference on a path that is really unreachable, for reasons buried in data the checker cannot see. That's not a bug in the tool — it's the price of a proof that holds for all inputs. And there is not one checker but several. mypy and pyright are independent implementations of roughly the same PEP 484 rules. They read the same annotations and the same typeshed, and they can quietly disagree at the edges where the spec is loose. Two engines, one rulebook, occasionally two verdicts.

SYNTAX · mypy — installing and running the second readera separate program, on its own release cycle, that reads your source and never imports it
python -m pip install mypy -- into the Ch 22 venv. it is NOT bundled with python. python -m mypy file.py -- THIS interpreter's mypy. the form that is never wrong. mypy file.py -- the console script; same program, but PATH decides which python -m mypy . -- every .py from here down python -m mypy --strict file.py -- every optional check switched on python -m mypy --version -- which checker actually answered you -- what it prints, and how to read it: file.py:9: error: Argument 1 to "as_clock" has incompatible type ... [arg-type] FILE LINE SEVERITY the human sentence the ERROR CODE exit code 0 -- clean exit code 1 -- errors found (this is what CI reads) x: int = "no" # type: ignore[assignment] -- silence ONE error, by name
it is not bundledCPython ships no checker. mypy is a package you install, exactly like the rich of Chapter 22, into one venv.
python -m mypyThe -m form runs the mypy that this interpreter owns. Bare mypy runs whichever one PATH found first.
what it readsYour source text, plus typeshed — the bundled stubs for the standard library. It never imports your module and never runs a line.
the error lineAlways file:line: error: sentence [code]. That bracketed code is the name you use in a type: ignore or a config file.
the exit code0 for clean, 1 when it found something. That single number is how a CI job turns checking into a gate.
# type: ignore[code]Silences exactly one error on one line. Always name the code — a bare ignore hides every future error there too.
pyrightThe other mainstream checker, written by Microsoft and the engine behind VS Code's Pylance. Same PEPs, occasionally a different verdict.
you type
# duration.py -- runs clean today. one branch has never executed.
def as_clock(seconds: int) -> str:
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def report(seconds: int, fancy: bool = False) -> str:
    if fancy:
        return "~ " + as_clock(str(seconds)) + " ~"
    return as_clock(seconds)


print(report(340))
print(report(200))


# ---------- the terminal, in the Ch 22 venv ----------
$ python -m venv .venv
$ source .venv/Scripts/activate      # unix: source .venv/bin/activate
$ python -m pip install mypy
$ python -m mypy --version
$ python duration.py
$ python -m mypy duration.py
$ echo $?
you see
$ python -m pip install mypy
Collecting mypy
  Using cached mypy-2.3.1-cp312-cp312-win_amd64.whl.metadata (2.4 kB)
Collecting typing_extensions>=4.6.0 (from mypy)
Collecting mypy_extensions>=1.0.0 (from mypy)
Collecting pathspec>=1.0.0 (from mypy)
Collecting librt>=0.13.0 (from mypy)
Collecting ast-serialize<1.0.0,>=0.6.0 (from mypy)
Using cached mypy-2.3.1-cp312-cp312-win_amd64.whl (11.2 MB)
Installing collected packages: typing_extensions, pathspec,
    mypy_extensions, librt, ast-serialize, mypy
Successfully installed ast-serialize-0.8.0 librt-0.15.0 mypy-2.3.1
    mypy_extensions-1.1.0 pathspec-1.1.1 typing_extensions-4.16.0

$ python -m mypy --version
mypy 2.3.1 (compiled: yes)

$ python duration.py
5:40
3:20

$ python -m mypy duration.py
duration.py:9: error: Argument 1 to "as_clock" has incompatible type
    "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

$ echo $?
1
where beginners trip
  • The program is green and the checker is red, and both are right. Line 9 lives in a branch today's calls never take.
  • Install into the wrong environment and mypy checks a world your code does not live in. Use python -m and the venv's own interpreter.
  • Checkers move. On mypy 1.11.2 the 3.12 statement type Vector = list[float] was rejected outright; on 2.3.1 it passes silently.
  • Success: no issues found is not your code is correct. It means no rule was broken among the annotations you actually wrote.
  • A bare # type: ignore silences every error on that line forever, including the one you introduce next year. Name the code.
  • Imports it cannot resolve become Any and quietly stop checking. Install the library's stubs, or say --ignore-missing-imports knowing what you gave up.
one sourcedef check(n):if n > 0:result = "ok"else:result = Nonereturn resultINTERPRETERone run, sampledn = 5result = "ok"else: not takenCHECKERall runs, provenn: intresult: str | Noneboth branches foldedtogether, every time
Fig — Testing samples runs; checking proves over all of them — the interpreter walks one concrete path, the checker folds every branch into one verdict.
reveal_type IS THE CHECKER THINKING ALOUD
You can watch the environment directly. Write reveal_type(y) anywhere and run mypy — it prints a note like Revealed type is "builtins.float", then removes the call from consideration (it's not a real function; it exists only for the checker). It's a stethoscope on the typing environment at that exact program point. Sprinkle it wherever you're arguing with the tool about what it thinks a name is.
Wait — if the checker is just a walk that binds names to types, could you write one in an afternoon? A toy one, yes — loop over a list of (name, literal) pairs, and for each, record name → type(literal).__name__ in a dict. That eighty-character loop is the whole idea; mypy is that loop plus twenty years of edge cases — operator overload tables, generics, narrowing, typeshed. The kernel is not magic. It's an environment-maintaining walk, exactly like the interpreter, minus the running.

But a single type per name is a straitjacket — real code guards, branches, and re-checks. How does the checker learn that x is one type here and another type three lines down? Narrowing. →

03Narrowing: how control flow teaches the checker

Here is the moment that first makes the checker feel like it's reading your mind. You have a name that might be None. You write the obvious guard — if x is None: return — and then, below it, you call x.upper(). The annotation still says x could be None, so by the letter of it, x.upper() should be forbidden. Yet mypy accepts it without a word. It didn't ignore the rule — it learned something from your guard, and the mechanism it used is the single most important idea in practical typing: narrowing.

The key realization is that the typing environment is not one fixed type per name. It is a type per program point. As the checker walks the control-flow graph, the type it holds for x is a function of where in the code you are. At certain expressions, recognized as type guards, it splits that type. When the checker meets if x is None: with an incoming x: str | None, it partitions the possibilities across the two out-edges. On the edge into the if body it binds x: None. On the fall-through/else edge it binds x: str. That's because if x is not None, the only remaining member of the union is str. Two edges, two facts.

narrow.pypython
def greet(name: str | None) -> None:
    # here the checker holds  name: str | None
    if name is None:
        # on THIS edge  name: None
        return                 # edge dead-ends; reachability analysis notes it
    # the only edge that reaches here is the else-edge:  name: str
    print(name.upper())    # str.upper() exists -> accepted, no cast, no ignore

The return is doing quiet, essential work. The checker's reachability analysis understands that return terminates the block, so the None edge simply stops there. The only edge that flows down to name.upper() is the else-edge, and on that edge the environment says name: str. The method exists on str, so the call type-checks. Not because the checker got lax, but because at that line it has proven name cannot be None. Tie it straight back to Vol 1. The interpreter's namespace maps a name to different objects over time. The checker's environment maps a name to different, narrower types over the control-flow graph. Same shape of idea, one axis swapped from time to place.

The recognized guard forms are a specific, learnable vocabulary. This is not fuzzy pattern-matching, it's a fixed list. x is None and x is not None split off None. isinstance(x, C) narrows to C on the true edge. type(x) is C narrows to exactly C. A plain truthiness test if x: narrows away None and other falsy members for types where that's sound. Comparing == against a literal or an enum value narrows to that literal's type. And you can extend the vocabulary yourself. A TypeGuard (PEP 647) or TypeIs (PEP 742) function is a predicate whose return annotation tells the checker "if this returned True, narrow the argument to this." Each of these is a rule the checker applies at an edge to shrink a name's type set.

NOW WRITE IT YOURSELFthree real mypy errors on a program that runs perfectly — fix each, and say what it was protecting
First, type it and run it. Save shuffle.py exactly as printed below, then run python shuffle.py. It works. It prints a sensible banner and a True, and nothing anywhere complains. Now run python -m mypy shuffle.py and read the three errors that come back — they are printed for you underneath, verbatim, from a real run of mypy 2.3.1.

Second, answer before you edit a character. For each of the three errors, write one sentence naming the input that would make it real. Error one is on the parameter avoid: what value must a caller pass — or not pass — for the declared type to be a lie? Error two is on row[0]: which single argument to banner() turns that line into an AttributeError at three in the morning? Error three is on the append: the program still runs after it, so say precisely which later call would blow up on the row you just added, and why nothing has yet. A fix you cannot explain is a fix the next person will undo.

Third, fix all three, then re-run both commands. One error is fixed by widening a type, one by adding a guard, and one by correcting a value rather than a hint — and deciding which is which is the whole exercise. Do not reach for Any, and do not reach for # type: ignore. When mypy prints Success, run the program again and make sure the output still makes sense: a fix that silences the checker but changes the behaviour is not a fix. One hint, no more: after you fix banner(), call it with a title that is not in the library and see what your guard chose to say.

The three errors, verbatim.
shuffle.py:21: error: Incompatible default for parameter "avoid" (default
    has type "None", parameter has type "str")  [assignment]
shuffle.py:21: note: PEP 484 prohibits implicit Optional. Accordingly, mypy
    has changed its default to no_implicit_optional=True
shuffle.py:28: error: Value of type "tuple[str, int] | None" is not
    indexable  [index]
shuffle.py:31: error: Argument 1 to "append" of "list" has incompatible
    type "tuple[str, str]"; expected "tuple[str, int]"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)
show the solution
# ---------- shuffle.py  (the one you type: it RUNS) ----------
"""shuffle.py -- it runs today and prints something sensible. mypy disagrees."""
import random

Track = tuple[str, int]

LIBRARY: list[Track] = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]


def as_clock(seconds: int) -> str:
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def find(title: str, library: list[Track]) -> Track | None:
    for row in library:
        if row[0] == title:
            return row
    return None


def pick(library: list[Track], avoid: str = None) -> Track:
    choices = [row for row in library if row[0] != avoid]
    return random.choice(choices)


def banner(title: str, library: list[Track]) -> str:
    row = find(title, library)
    return f"Now playing: {row[0]} ({as_clock(row[1])})"


LIBRARY.append(("Nightcall", "348"))

print(banner("Levels", LIBRARY))
print("avoided Levels:", pick(LIBRARY, avoid="Levels")[0] != "Levels")


$ python shuffle.py
Now playing: Levels (5:40)
avoided Levels: True


# The three answers, before the edits.
#
# 1. line 21 -- `avoid: str = None`. The default IS the counter-example. Call
#    pick(LIBRARY) with no `avoid` at all and the parameter holds None, which
#    is not a str. The declaration was already false on the most common call.
#
# 2. line 28 -- `row[0]`. Pass any title that is not in the library, say
#    banner("Enter Sandman", LIBRARY), and find() returns None. Subscripting
#    None raises TypeError. Today every call happens to use a title that hits.
#
# 3. line 31 -- ("Nightcall", "348") puts a str where the row promised an int.
#    Nothing breaks yet because appending never inspects the value. It breaks
#    the moment as_clock() reaches that row: divmod("348", 60) is a TypeError.
#    The bug and the crash are in different files, minutes apart.


# ---------- shuffle_fixed.py  (three edits, nothing else) ----------
"""shuffle_fixed.py -- three edits, each one the type telling you what it needed."""
import random

Track = tuple[str, int]

LIBRARY: list[Track] = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]


def as_clock(seconds: int) -> str:
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def find(title: str, library: list[Track]) -> Track | None:
    for row in library:
        if row[0] == title:
            return row
    return None


def pick(library: list[Track], avoid: str | None = None) -> Track:   # FIX 1
    choices = [row for row in library if row[0] != avoid]
    return random.choice(choices)


def banner(title: str, library: list[Track]) -> str:
    row = find(title, library)
    if row is None:                                                  # FIX 2
        return f"Not in the library: {title}"
    return f"Now playing: {row[0]} ({as_clock(row[1])})"


LIBRARY.append(("Nightcall", 348))                                   # FIX 3

print(banner("Levels", LIBRARY))
print(banner("Nightcall", LIBRARY))
print(banner("Enter Sandman", LIBRARY))
print("avoided Levels:", pick(LIBRARY, avoid="Levels")[0] != "Levels")


$ python -m mypy shuffle_fixed.py
Success: no issues found in 1 source file

$ python shuffle_fixed.py
Now playing: Levels (5:40)
Now playing: Nightcall (5:48)
Not in the library: Enter Sandman
avoided Levels: True


# Why each fix is the one the type asked for.
#
# FIX 1 -- WIDEN the type, do not change the default. `str | None` states the
#   truth: this parameter really can be None, and every use of `avoid` inside
#   pick() must survive that. The comparison row[0] != avoid already does.
#   Writing `avoid: str = ""` would also silence mypy -- and would quietly
#   change behaviour for a caller who passes "" on purpose.
#
# FIX 2 -- ADD the guard. There is no annotation that makes `row[0]` safe,
#   because the danger is real: find() genuinely returns None on a miss. The
#   only honest fix is to handle the miss, which is what the checker was
#   asking for. Note the shape: after `if row is None: return ...`, the None
#   edge dead-ends, so below the guard row is a plain tuple[str, int] again.
#
# FIX 3 -- FIX THE VALUE, not the hint. The row was simply wrong: 348 seconds
#   is a number. Widening Track to tuple[str, int | str] would "fix" the error
#   and poison every function that reads a duration. When the data is wrong,
#   the type is the messenger -- do not shoot it.
isinstance_split.pypython
def length_of(x: int | str) -> int:
    if isinstance(x, int):
        return x + 1      # this arm: x: int  -> int arithmetic is fine
    else:
        return len(x.upper())  # this arm: x: str  -> .upper() is fine

Now the honest edge, because it's a real bug class. Narrowing is a fact at a point in flow, and it can be destroyed. Say you narrow x to non-None, then reassign it from a function returning str | None. The checker must throw the old fact away. The name widens back to its declared type, because the new value could once again be None. Likewise, calling a function that the checker must assume could mutate a narrowed attribute can invalidate the narrowing. This is not the tool being fussy. It is the tool refusing to keep believing something you just made untrue. When mypy suddenly "forgets" that x was safe, look for the reassignment between the guard and the use.

def shout(x: str | None):if x is None:return ""return x.upper()x: str | Nonex: Nonedead-ends at returnx: str · safethe type of x is a function of WHERE you stand
Fig — The is-None branch returns, so its None rail dead-ends; only the x:str rail reaches x.upper() — where you stand decides x's type.
THE ONE IDEA TO CARRY FORWARD
A name does not have a static type — it has a type at every line, and guards reshape it. x can be str | None at the top of a function, None inside one branch, and str below it, all in six lines. Narrowing is what makes optional and union types usable instead of a straitjacket: you don't fight the union, you walk past a guard and the checker updates its belief.
Wait — why does if x: narrow away None for a str | None but is riskier for, say, an int | None? Because if x: narrows away everything falsy, and 0 is a perfectly valid, non-None int that is also falsy. On the true edge you get int, but on the false edge the checker can't collapse to None — the value might be 0. For optionals where the wrapped type has falsy members, reach for the explicit is None; it splits cleanly on identity, with no truthiness ambiguity to reason around.

Narrowing only matters because a name can carry several types at once. Time to name that thing directly — Optional and Union, and why they turn the most common crash in Python into a compile-time error. →

04Optional and Union: a name with several types

Count the times production has bitten you with AttributeError: 'NoneType' object has no attribute .... A function returned None — the lookup missed, the list was empty, the key wasn't there — and you, three files away, dotted into the result as if it were always a real object. This is not an exotic bug. By volume it is close to the Python bug. What makes it lethal is that nothing marks the moment None entered. Optional and Union are the fix, and they are not decoration. They are the checker's way of forcing you to face the None case at the exact seam where it's born. That turns a category of 3 a.m. crashes into errors you clear before you ship.

A Union is a static set of types. When the checker holds x: int | str (the | spelling, PEP 604, since 3.10), it is recording that at this point x may hold any member of that set. So it permits only operations valid for every member. You may call a method only if it exists on the int interface and the str interface. Anything present on just one is rejected, because the checker cannot prove which member you actually have. This is the meet of the interfaces, the common ground. And Optional[T] is nothing but Union[T, None]. It is pure sugar, the same object underneath.

optional_is_union.pypython
from typing import Optional, Union

print(Optional[int] is Union[int, None])   # True — the same normalized object
print(Optional[int])                     # typing.Optional[int]  == Union[int, None]

Now the crux of why Optional protects you. The static type of None is NoneType, whose interface is very nearly empty — it has essentially no useful methods. So while None is still a possible member of the set, the meet of "whatever T offers" and "almost nothing" is almost nothing, and the checker rejects x.anything(). The only way to earn access to T's methods is to remove None from the set, which is precisely the narrowing of the previous section. So Optional mechanically compels a None-check before use. It is not asking politely, and the type algebra leaves you no other door.

SYNTAX · int | None — writing down that a value might be missingthe modern pipe, the legacy Optional, and the = None default you have been writing since Ch 9
x: int | None -- modern. PEP 604, Python 3.10+. read it aloud: "int OR None". x: Optional[int] -- the legacy spelling. IDENTICAL meaning. from typing import. x: int | str | None -- unions chain; None is just one more member x: Union[int, str] -- the legacy spelling of int | str -- THE pattern. Ch 9's sentinel default, now with its type written down: def f(x: int | None = None) -> int: if x is None: -- the sentinel check AND the narrowing, one line x = 0 -- below here x is plain int, and mypy knows it return x + 1 -- the mutable-default cure from Ch 9, typed: def g(bucket: list[str] | None = None) -> list[str]: if bucket is None: bucket = [] -- a FRESH list per call. never bucket=[] in the signature.
|Reads as or. int | None says this name holds an int, or it holds None, and you must be ready for both.
Optional[int]The same type, older spelling. It means may be None — it has never meant this argument may be omitted.
None the typeNone is a real object of type NoneType, with essentially no methods. That emptiness is exactly what forces your guard.
= NoneChapter 9's sentinel, unchanged. The default announces nothing was passed, and the type now says so out loud too.
why not = []A default is evaluated once, at def time, so a shared list leaks between calls. None plus a fresh [] is the cure.
the guard is not optionalYou cannot reach a member's methods while None is still in the set. The checker refuses until you rule it out.
older PythonsThe | spelling needs 3.10 at runtime. On 3.7–3.9 write Optional[int], or add from __future__ import annotations.
you type
# optional_writer.py -- the None-default pattern, and the mistake it prevents
def add_track(title: str, bucket: list[str] | None = None) -> list[str]:
    if bucket is None:              # the sentinel check AND the narrowing, one line
        bucket = []
    bucket.append(title)
    return bucket


def first_title(bucket: list[str] | None) -> str:
    return bucket[0]                # no guard -- the checker refuses this


print("fresh call 1 :", add_track("Levels"))
print("fresh call 2 :", add_track("Titanium"))
mine = add_track("Levels")
print("passed in    :", add_track("Blinding Lights", mine))
print("with a list  :", first_title(["Levels"]))
print("with None    :", first_title(None))


# ---------- the terminal ----------
$ python optional_writer.py
$ python -m mypy optional_writer.py
you see
$ python optional_writer.py
fresh call 1 : ['Levels']
fresh call 2 : ['Titanium']
passed in    : ['Levels', 'Blinding Lights']
with a list  : Levels
Traceback (most recent call last):
  File "C:\tmp\ch23lang\optional_writer.py", line 18, in <module>
    print("with None    :", first_title(None))
                            ^^^^^^^^^^^^^^^^^
  File "C:\tmp\ch23lang\optional_writer.py", line 10, in first_title
    return bucket[0]
           ~~~~~~^^^
TypeError: 'NoneType' object is not subscriptable

$ python -m mypy optional_writer.py
optional_writer.py:10: error: Value of type "list[str] | None" is not
    indexable  [index]
Found 1 error in 1 file (checked 1 source file)
where beginners trip
  • Optional[int] does not mean optional parameter. It means the value may be None; omitting an argument is what a default does.
  • Implicit Optional is gone. def f(x: int = None) is now an error — write x: int | None = None and say what you meant.
  • A bare return counts as returning None. One early exit in a -> int function and the whole signature is a lie.
  • Narrowing is a fact about a point in the flow, not about the name. Reassign after the guard and None walks straight back in.
  • Prefer x is None over x == None. A class can redefine __eq__; nothing can redefine identity with the one None object.
  • Both lines above are the same bug, seen twice. The traceback needed the wrong input; the checker needed only the source.

Keep the metal-up honesty sharp, because | means two different things depending on where it appears. As a runtime expression, int | str actually constructs a value. It builds a types.UnionType object you can inspect, and since 3.10 isinstance(3, int | str) genuinely works at runtime. But Union[...] and Optional[...] from typing construct typing.Union special forms instead. Both are real objects the annotation machinery evaluates and files away. And both do no checking whatsoever on assignment. Storing a str into a name annotated int | None raises nothing. The union is meaningful only to the checker reading the annotation, never to the STORE that runs.

union_at_runtime.pypython
U = int | str
print(type(U))              # <class 'types.UnionType'> — a real, live object
print(isinstance(3, U))     # True   (3.10+)
print(isinstance(3.0, U))   # False — but no assignment was ever checked

Here is the part that makes unions a superpower rather than a chore: the asymmetry of the seam. A union is introduced at a boundary. A function declares def find_user(id) -> User | None, and from that one honest signature, the obligation to handle None propagates outward to every caller, forever. The checker turns a single truthful return type into enforced discipline everywhere the value flows downstream. Compare the pre-hints world. A None-returning function was a landmine, documented at best in a docstring nobody opened, and the crash surfaced far from the function that planted it. With -> User | None, the person who knows the function can miss states it once, at the source. The type system then carries that warning to everyone who ever touches the result.

user : User | NoneUserNoneuser.namelockeduser.emaillockedif useris not None:NoneNone member droppeduser : UserUseruser.nameuser.email
Fig — A union is the set of what a value might be; narrowing removes members until every remaining one makes the operation safe.
THE CRASH LIVES AT THE SEAM, NOT THE CALL
When find_user() returns None and you dot into it, the traceback points at your line — but the bug was born at the function boundary where None became possible and went unlabeled. An honest -> User | None moves the fix to where the knowledge is. Annotate the return where the None is introduced, and every downstream AttributeError becomes a checker error at the seam instead of a crash at the call.
Wait — so isinstance(3, int | str) works at runtime — does that mean the annotation x: int | str is being enforced somewhere? No, and the gap is the whole lesson. The isinstance call is a check you chose to run, with real cost, at a moment you picked. The annotation x: int | str triggers no such call — it's filed in __annotations__ and read only by the checker. The same three characters, int | str, are an executable runtime test in one place and an inert static note in another. Which one you got depends entirely on whether it sat inside an expression that ran, or an annotation that didn't.

A union is a name that might be one of several types. But how does a list[int] remember its elements are int deep in code that never saw a single literal? Types that carry other types: generics. →

05Generics: types parameterized by types

You have already leaned on this without naming it. You pulled a value out of a list[int] and the checker knew it was an int. It let you write x + 1 with no cast, no question. Then you pulled from a bare list, unparameterized, and everything came out untyped, so nothing downstream was checked. Somewhere a container is carrying its element type through code that never saw the elements. That carrying is what makes types survive being stored in and fetched from collections — exactly where types otherwise go to die. It's called a generic.

A generic is a type with a hole in it. list is really list[T], where T is a type parameter — a placeholder. Writing list[int] substitutes int for T and yields a concrete static type. The checker implements the hole with a TypeVar, and the magic is that the same variable can appear in more than one spot, tying them together:

first.pypython
from typing import TypeVar
T = TypeVar('T')

def first(xs: list[T]) -> T:      # same T in and out: element type == return type
    return xs[0]

n = first([1, 2, 3])   # arg is list[int] -> unify T := int -> n: int
s = first(["a", "b"])     # arg is list[str] -> unify T := str -> s: str

Follow the checker's move on first([1, 2, 3]). It reads the argument's static type, list[int], and sets it against the parameter's declared type, list[T]. Then it solves for the placeholder: T := int. That step is called unification — matching a pattern with a hole against a concrete type and reading off what the hole must be. Having bound T = int, it substitutes into the return type T and concludes the result is int. This is parametric polymorphism: one definition serving every element type. The machinery is type inference by unification, the same core idea as Hindley-Milner, bent to fit Python. And every bit of it is symbol manipulation at check time, with zero runtime cost.

Now the case you actually hit daily — dict, generic in two parameters, dict[K, V]. Take our running example, a table of durations:

durations.pypython
durations: dict[str, int] = {"Blinding Lights": 200, "Titanium": 245}
total = durations["Blinding Lights"] + durations["Titanium"]
# dict.__getitem__ stub:  (self: dict[K, V], key: K) -> V
# with K=str, V=int the lookup's result type is V = int, so total: int

The checker knew durations["Blinding Lights"] was an int without seeing 200. Indexing invokes dict.__getitem__, whose typeshed stub reads (self: dict[K, V], key: K) -> V. With K = str and V = int bound from the annotation, the return type resolves to V = int. The parameter threaded straight through the lookup. That is the entire reason a value can be fetched from deep inside a collection and still arrive with a known type.

SYNTAX · list[int], dict[str, float] — saying what is inside the boxthe four containers you use daily, spelled the 3.9+ way, and the legacy typing.List you will still meet
list[int] -- a list whose every element is an int dict[str, float] -- keys are str, values are float. K first, then V. set[str] tuple[str, int] -- EXACTLY two slots, in that order. a record. tuple[int, ...] -- any number of slots, all int. the literal ... is the spelling. list[list[int]] -- nesting is just more brackets list[tuple[str, int]] -- our jukebox library: rows of (title, seconds) dict[str, list[str]] -- a playlist per user -- the legacy spelling, everywhere in code written before 3.9: from typing import List, Dict, Tuple, Set List[int] -- same MEANING as list[int]; deprecated since 3.9 List[int] == list[int] -- False. same meaning, two different objects.
the bracketsNot a call and not an index. list[int] builds a types.GenericAlias, a small object recording which container and which parameters.
dict[K, V]Two parameters, keys first. This is what lets a lookup deep inside a dict come back with a known type, as we just traced.
tuple[str, int]Fixed arity and fixed order — a record. Adding a third slot is a type error, not a longer tuple.
tuple[int, ...]The homogeneous tuple: any length, one element type. The ... is literal syntax here, not something for you to fill in.
nestingParameters are themselves types, so they nest without limit. list[tuple[str, int]] is exactly the jukebox library from Chapter 19.
typing.ListThe pre-3.9 spelling, still importable and still everywhere in older code. Read it fluently; write the lowercase form.
erased at runtimeThe object keeps no memory of its parameters. type([1, 2]) is plain list, which is why nothing is checked when you append.
you type
# generic_errors.py -- four honest mistakes. python runs it without a word.
titles: list[str] = ["Blinding Lights", 340]        # an int in a list of str
durations: dict[str, float] = {"Levels": "340"}     # a str where float was promised
pair: tuple[str, int] = ("Levels", 340, 1)          # three slots, two declared
counts: tuple[int, ...] = (1, 2, 3, 4, 5)           # any length is fine here
titles.append(340)                                  # the same mistake, later


# ---------- the terminal ----------
$ python generic_errors.py
$ echo $?
$ python -m mypy generic_errors.py
you see
$ python generic_errors.py

$ echo $?
0

$ python -m mypy generic_errors.py
generic_errors.py:1: error: List item 1 has incompatible type "int";
    expected "str"  [list-item]
generic_errors.py:2: error: Dict entry 0 has incompatible type "str":
    "str"; expected "str": "float"  [dict-item]
generic_errors.py:3: error: Incompatible types in assignment (expression
    has type "tuple[str, int, int]", variable has type "tuple[str, int]")
    [assignment]
generic_errors.py:5: error: Argument 1 to "append" of "list" has
    incompatible type "int"; expected "str"  [arg-type]
Found 4 errors in 1 file (checked 1 source file)
where beginners trip
  • Four wrong containers, no output, exit code 0. Nothing in CPython ever looks inside a list to check what you put there.
  • tuple[int] means a one-element tuple, not a tuple of ints. The tuple of ints is tuple[int, ...], dots and all.
  • List[int] == list[int] is False. They mean the same thing to a checker and are two different objects at runtime.
  • isinstance(x, list[int]) raises TypeError. The parameters are erased, so there is nothing left at runtime to test against.
  • An unparameterised list in a hint means list[Any], which quietly switches element checking off. Say what is inside.
  • Annotating an empty literal is the one place a hint is load-bearing: rows: list[Track] = [] gives inference something to work from.

And now the metal-up truth that keeps you honest, because it is genuinely surprising. list[int] at runtime is a real object, a types.GenericAlias, built by list.__class_getitem__(int). You can print it, and it has fields: list[int].__origin__ is list, and list[int].__args__ == (int,). But the interpreter erases the parameter for actual objects. The list [1, 2, 3] keeps no memory of int. Ask type([1, 2, 3]) and you get plain list. The [int] exists only as annotation metadata, never as a property of the container that holds the values.

erasure.pypython
GA = dict[str, int]
print(type(GA))        # <class 'types.GenericAlias'> — the parameterization is a real object
print(GA.__origin__)   # <class 'dict'>
print(GA.__args__)     # (<class 'str'>, <class 'int'>)
print(type({"a": 1}))  # <class 'dict'> — the actual object erased K and V

So generics are check-time substitution over runtime-erased parameters — fiercely powerful in the static world, invisible in the dynamic one. The modern spelling drops the boilerplate: PEP 695 (3.12) lets you write def first[T](xs: list[T]) -> T and class Box[T]: with the type parameter declared inline, no separate TypeVar and no Generic[T] base. Same unification underneath, less ceremony on top.

SYNTAX · naming a shape — the alias, the TypedDict, and the @dataclassthree ways to write a shape down once, and the honest rule for which one a given piece of data wants
-- 1. THE ALIAS. a name for a type you keep retyping. Track = tuple[str, int] -- works on every version. no import. just a name. type Track = tuple[str, int] -- PEP 695, Python 3.12+. lazily evaluated. Library = list[Track] -- aliases compose out of aliases -- 2. THE TYPEDDICT. the shape of a dict, key by key. it IS still a dict. from typing import TypedDict, NotRequired class Config(TypedDict): name: str volume: int device: str | None -- present, but may hold None theme: NotRequired[str] -- may be ABSENT; must be str if present -- 3. THE DATACLASS (Ch 12). a real class; the hints ARE the fields. from dataclasses import dataclass @dataclass class Track: title: str seconds: int = 0 -- generates __init__, __repr__, __eq__ ... checks nothing
the aliasJust a name bound to a type object. Write Track once and every signature that mentions a row gets shorter and truer.
type Track = ...The 3.12 statement form. It builds a TypeAliasType and defers evaluation, so a name defined later still resolves.
TypedDictFor data that must stay a dict — the JSON of Chapter 18, a config, a row from an API. Keys and value types, written down.
NotRequiredMarks a key that may be missing entirely. That is a different question from str | None, which is present but empty.
the dataclassFor data you own and want to give behaviour. You get __init__, __repr__ and __eq__ from the same field hints.
which to reach forDict in, dict out — TypedDict. Objects with methods — @dataclass. A shape you only name — an alias.
none of them checkAt runtime a TypedDict builds a plain dict and a dataclass stores whatever you hand it. The verdict is still the checker's alone.
you type
# shapes_errors.py -- four wrong shapes. python builds every one of them.
from dataclasses import dataclass
from typing import TypedDict

Seconds = int                           # the plain alias -- no version worries


class TrackDict(TypedDict):
    title: str
    seconds: Seconds


@dataclass
class Track:
    title: str
    seconds: Seconds


ok: TrackDict = {"title": "Levels", "seconds": 340}
typo: TrackDict = {"title": "Levels", "secs": 340}      # key misspelled
short: TrackDict = {"title": "Levels"}                  # a key is missing
bad = Track("Levels", "340")                            # a str into an int field

print(ok["seconds"], bad)
print(bad.seconds + 1)


# ---------- the terminal ----------
$ python -m mypy shapes_errors.py
$ python shapes_errors.py
you see
$ python -m mypy shapes_errors.py
shapes_errors.py:19: error: Missing key "seconds" for TypedDict
    "TrackDict"  [typeddict-item]
shapes_errors.py:19: error: Extra key "secs" for TypedDict
    "TrackDict"  [typeddict-unknown-key]
shapes_errors.py:20: error: Missing key "seconds" for TypedDict
    "TrackDict"  [typeddict-item]
shapes_errors.py:21: error: Argument 2 to "Track" has incompatible
    type "str"; expected "int"  [arg-type]
Found 4 errors in 1 file (checked 1 source file)

$ python shapes_errors.py
340 Track(title='Levels', seconds='340')
Traceback (most recent call last):
  File "C:\tmp\ch23lang\shapes_errors.py", line 24, in <module>
    print(bad.seconds + 1)
          ~~~~~~~~~~~~^~~
TypeError: can only concatenate str (not "int") to str
where beginners trip
  • The typo built a perfectly good dict and the dataclass stored a str in seconds. Only arithmetic, three lines later, objected.
  • A TypedDict is not a class you instantiate. TrackDict(title=...) returns a plain dict, and isinstance against it raises TypeError.
  • @dataclass does not validate. It reads your field hints to write __init__, then hands your value straight through untouched.
  • The type X = ... statement needs 3.12 and a checker that knows it. mypy 1.11.2 rejected ours outright; 2.3.1 accepted it.
  • NotRequired[str] and str | None answer different questions: may the key be absent, or may the value be empty?
  • An alias is a runtime name, so it must be defined before it is used — unless you use the 3.12 type form, which defers.
the stencil — one hole named T, cut in twicedef first(xs: list[T]) ->T← fills toothe call — an int chip drops into the holefirst([1, 2, 3])intunify T := intruntime realitylist[1, 2, 3][int]erased at runtime
Fig — T is a stencil hole threaded through the signature: the call unifies T:=int for the checker, while the runtime list forgets its element type entirely.
NOW WRITE IT YOURSELFthree untyped defs from our own jukebox — annotate every seam, and defend the one that must be | None
First, type the three defs exactly as they are. Save them as annotate_this.py and run python -m mypy annotate_this.py before you touch anything. Write down what it says. That answer is the whole reason this exercise exists, and it should worry you slightly.

Second, annotate every seam — parameters and returns only. Add nothing inside the bodies except where inference genuinely needs help, and you will find exactly one such place. Start with as_clock, which is the easy one. Then longest, and think hard here: read its body and ask what it returns when the library is empty. Then clock_table, whose return type is a dict you have to describe in both parameters. Use an alias for the row so you are not typing tuple[str, int] five times — naming the shape once is half the value of doing this at all.

Third, prove the annotations earn their keep. Add these four lines under your defs and run mypy again: build LIBRARY as a real list of rows, then write print(longest(LIBRARY)[0]), then print(longest([])[0]), then print(as_clock("245")). Predict every verdict before you press Enter. Then run the same file with python and watch which of those lines actually survives. One of them works perfectly at runtime and is still flagged — explain, in one sentence, why the checker is right to flag it anyway. Finally, add the guard that makes longest usable and note how few characters it took.
show the solution
# ---------- annotate_this.py  (what you were given) ----------
"""annotate_this.py -- three defs, no hints. Annotate every seam."""


def as_clock(seconds):
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def longest(library):
    best = None
    for title, secs in library:
        if best is None or secs > best[1]:
            best = (title, secs)
    return best


def clock_table(library):
    return {title: as_clock(secs) for title, secs in library}


$ python -m mypy annotate_this.py
Success: no issues found in 1 source file

# Read that again. Three unannotated defs, and mypy has NOTHING to say --
# because by default it does not check the body of a function with no
# annotations. "Success" here means "I did not look."


# ---------- annotate_this_solved.py ----------
"""annotate_this_solved.py -- the same three defs, annotated at the seams."""

Track = tuple[str, int]                 # a row is (title, seconds)


def as_clock(seconds: int) -> str:
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def longest(library: list[Track]) -> Track | None:
    best: Track | None = None           # the ONE hint a body needs
    for title, secs in library:
        if best is None or secs > best[1]:
            best = (title, secs)
    return best


def clock_table(library: list[Track]) -> dict[str, str]:
    return {title: as_clock(secs) for title, secs in library}


LIBRARY: list[Track] = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]

top = longest(LIBRARY)
if top is None:
    print("empty library")
else:
    print("longest:", top[0], as_clock(top[1]))
print(clock_table(LIBRARY))
print(longest([]))


$ python -m mypy annotate_this_solved.py
Success: no issues found in 1 source file

$ python annotate_this_solved.py
longest: Levels 5:40
{'Blinding Lights': '3:20', 'Titanium': '4:05', 'Levels': '5:40'}
None


# The reasoning, seam by seam.
#
# as_clock(seconds: int) -> str
#   divmod(seconds, 60) and the :02d format both demand a number, and an
#   f-string always produces str. Both hints were already true; writing them
#   down just moves the truth to where a caller can read it.
#
# longest(library: list[Track]) -> Track | None
#   THE one that matters. Read the body: `best` starts as None, and if the
#   loop never runs, None is what comes back. So `-> Track` would be a lie
#   for exactly one input -- the empty list -- which is the input nobody
#   tests. `Track | None` states it, and then forces every caller to say
#   what an empty library should mean.
#
# best: Track | None = None
#   The one hint inside a body. Without it, mypy infers `best: None` from the
#   first assignment and then rejects the tuple on the next line. Annotate the
#   variable that is DECLARED empty and later filled -- that is the general rule.
#
# clock_table(library: list[Track]) -> dict[str, str]
#   Both parameters matter. Keys are titles (str) and values come out of
#   as_clock, which returns str. dict[str, str], not dict[str, int] -- the
#   whole point of the function is that the seconds became a clock.


# ---------- probes.py: the three probes, and their verdicts ----------
# (the same Track, as_clock and longest as above, then:)
LIBRARY: list[Track] = [("Blinding Lights", 200), ("Levels", 340)]
print(longest(LIBRARY)[0])              # flagged -- and it WORKS at runtime
print(longest([])[0])                   # flagged -- and it really crashes
print(as_clock("245"))                  # flagged -- str where int promised


$ python -m mypy probes.py
probes.py:19: error: Value of type "tuple[str, int] | None" is not
    indexable  [index]
probes.py:20: error: Value of type "tuple[str, int] | None" is not
    indexable  [index]
probes.py:21: error: Argument 1 to "as_clock" has incompatible type
    "str"; expected "int"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)

$ python probes.py
Levels
Traceback (most recent call last):
  File "C:\tmp\ch23lang\ex\probes.py", line 20, in <module>
    print(longest([])[0])                   # flagged -- and it really crashes
          ~~~~~~~~~~~^^^
TypeError: 'NoneType' object is not subscriptable


# Why the checker is right about line 19, which works.
#
# Line 19 and line 20 are the SAME expression. The only difference is the
# list you happened to pass, and the checker does not get to see your data --
# it reasons about every list you could ever pass. `longest(...)` returns
# `Track | None` for all of them, so the subscript is unsafe for all of them.
# Line 19 passing today is luck about an argument, not a property of the code.
# The guard is four words:
#
#     top = longest(LIBRARY)
#     if top is not None:
#         print(top[0])
THE ONE IDEA TO CARRY FORWARD
A generic is a stencil: T is a hole, and using the same hole in the input and the output ties the element type to the return type. The checker fills the hole by unification and threads the result through every lookup — so a value fetched from a dict[str, int] arrives typed int. The runtime, meanwhile, keeps none of it: the container remembers its values and forgets their type. Powerful statically, erased dynamically.
Wait — if list[int] is erased, why does isinstance(x, list[int]) raise TypeError: isinstance() argument 2 cannot be a parameterized generic? Because there is nothing to check against — the runtime can't verify "list whose elements are all int" without walking every element, and even then a later append could break it. So Python forbids the ask outright. You can test isinstance(x, list) (the origin) but never the parameter. It's the erasure, made into an error message: the container's type is real, its element type is a fiction only the checker believes.

Generics let types ride inside containers. But Vol 1's real dispatch was duck typing — "if it quacks" — and nominal hints betray that. Next: how to type-check duck typing itself. →

06Protocols: structural typing the checker can verify

Vol 1 sold you on Python's real nature: dispatch is duck typing. for x in thing works on anything with an __iter__. len(x) works on anything with a __len__. No inheritance, no declared kinship, no permission asked. The interpreter looks up a method slot on the object's type and calls it. Lineage never enters the room. And then the first hints you wrote quietly betrayed all of it. def f(x: Animal) demands that x descend from a specific class, an ancestry the duck-typed runtime never cared about. You need a way to write hints that say "anything shaped like this," so the static world finally matches the dynamic world's "if it quacks." That is a Protocol.

There are two flavors of subtyping, and naming them clears the fog. Nominal subtyping is the default in Java, C++, and Python's own isinstance-against-a-base. It says X is a subtype of Y exactly when X explicitly inherits from Y, so it's about declared ancestry. Structural subtyping says X is a subtype of Y exactly when X has the members Y requires, so it's about shape and ignores lineage entirely. Now the observation that reframes the whole language: Python's runtime dispatch is structural to the bone. len(x) calls x.__len__() and for calls x.__iter__(), so the interpreter checks for the slot on the object's type, never for a base class. The runtime has always been structural, and it was only the hints that pretended otherwise.

typing.Protocol brings that same structural rule to the checker. You declare a shape, and any class with matching members satisfies it — no inheritance, verified purely by member presence and signature compatibility:

quacker.pypython
from typing import Protocol

class Quacker(Protocol):        # a SHAPE, not a base class to inherit
    def quack(self) -> str: ...

class Duck:                     # does NOT inherit Quacker
    def quack(self) -> str: return "quack"

class Person:                   # also unrelated
    def quack(self) -> str: return "i am quacking"

def make_noise(q: Quacker) -> str:   # accepts anything shaped like Quacker
    return q.quack()

make_noise(Duck())      # checks: Duck HAS quack() -> str  -> OK
make_noise(Person())    # checks: Person HAS quack() -> str -> OK, no shared base

Neither Duck nor Person inherits from Quacker, and both type-check. The checker asked one question — does it have a quack returning str? — and both answered yes. This is the checker's version of "if it quacks, it's a duck," verified statically by member matching. You are no longer choosing between duck typing and type checking — you are type-checking duck typing.

Now the honest caveat, and it matters. Protocols are checked structurally at static time, and at runtime they normally do nothing at all. But mark one @runtime_checkable and you may write isinstance(duck, Quacker). Here is the trap: that runtime isinstance only checks that the attribute names exist, and it does not verify signatures or return types. It's a shallow hasattr-style sweep. A class with a quack attribute of the wrong signature, even a quack that's an integer, will pass isinstance(x, Quacker) while failing the static check. The static verification is strictly deeper than the runtime one:

shallow_runtime.pypython
from typing import Protocol, runtime_checkable

@runtime_checkable
class Quacker(Protocol):
    def quack(self) -> str: ...

class Fake:
    quack = 42              # an int, not even a method

print(isinstance(Fake(), Quacker))  # True! name exists -> shallow check passes
# a static checker would REJECT Fake as a Quacker: 42 is not () -> str

Contrast this with the ABCs in collections.abc, which are the nominal path. You register or inherit to belong, though several use __subclasshook__ to add duck-style checks on top. And notice how much of the standard library was Protocol-shaped all along — Iterable, Iterator, Sized, SupportsInt. These are the checker's formal names for the dunder contracts Vol 1 leaned on the whole time. Sized is just "has __len__." Iterable is just "has __iter__." The duck contracts were real. Protocols finally gave them a spelling the static world can verify.

NOW WRITE IT YOURSELFa settings dict every function passes around — give it a shape, then break it six ways
First, type config_before.py and run both commands. It is the configuration dict every project grows: a name, a volume, a shuffle flag, an optional device. Run it, then run python -m mypy config_before.py. Read the second call to describe() carefully before you look at what mypy said, then look. The volume there is the string "9", the program prints a cheerful line, and the checker reports success. Write one sentence explaining why it reported success — the reason is about describe, not about the dict.

Second, write the shape down once. Declare class Config(TypedDict) with the four keys and their real types, annotate CONFIG: Config and def describe(cfg: Config) -> str, and change nothing else about the logic. Then add a fifth key, theme, which is allowed to be absent entirely — that is a different tool from the one device needed, and picking the right one is the point. While you are in describe, notice what your device check is now doing double duty as.

Third, break it on purpose, six ways. In a second file, write: a config whose volume is "9"; one missing device entirely; one where shuffle is misspelled shufle; a read of CONFIG["volme"]; and an assignment CONFIG["volume"] = "9". Run mypy once and count the errors before you scroll. Then answer the question the whole exercise is built around: which of those six would a unit test have caught, and when? One hint — look closely at what mypy says after the volme error. It does something a linter cannot.
show the solution
# ---------- config_before.py  (what you start with) ----------
"""config_before.py -- a settings dict passed everywhere, shaped by nothing."""
CONFIG = {
    "name": "kitchen jukebox",
    "volume": 7,
    "shuffle": True,
    "device": None,
}


def describe(cfg):
    line = cfg["name"] + " at volume " + str(cfg["volume"])
    if cfg["shuffle"]:
        line += " (shuffling)"
    if cfg["device"] is not None:
        line += " -> " + cfg["device"]
    return line


print(describe(CONFIG))
print(describe({"name": "loft", "volume": "9", "shuffle": False, "device": None}))


$ python config_before.py
kitchen jukebox at volume 7 (shuffling)
loft at volume 9

$ python -m mypy config_before.py
Success: no issues found in 1 source file

# Why "Success" -- describe() has no annotations, so mypy skips its body
# entirely and treats every call to it as returning Any. The dict was never
# described, and the function was never checked. Two holes, one silence.


# ---------- config_after.py  (the shape, written once) ----------
"""config_after.py -- the same dict, with its shape written down once."""
from typing import NotRequired, TypedDict


class Config(TypedDict):
    name: str
    volume: int
    shuffle: bool
    device: str | None
    theme: NotRequired[str]              # may be absent; must be str if present


CONFIG: Config = {
    "name": "kitchen jukebox",
    "volume": 7,
    "shuffle": True,
    "device": None,
}


def describe(cfg: Config) -> str:
    line = f"{cfg['name']} at volume {cfg['volume']}"
    if cfg["shuffle"]:
        line += " (shuffling)"
    if cfg["device"] is not None:        # narrows str | None to str
        line += " -> " + cfg["device"]
    return line


print(describe(CONFIG))
print(describe({"name": "loft", "volume": 9, "shuffle": False,
                "device": "speaker-2", "theme": "dark"}))


$ python -m mypy config_after.py
Success: no issues found in 1 source file

$ python config_after.py
kitchen jukebox at volume 7 (shuffling)
loft at volume 9 -> speaker-2


# ---------- config_broken.py  (six ways to break it) ----------
from config_after import CONFIG, Config, describe

bad: Config = {"name": "loft", "volume": "9", "shuffle": False, "device": None}
missing: Config = {"name": "loft", "volume": 9, "shuffle": False}
typo: Config = {"name": "loft", "volume": 9, "shufle": False, "device": None}

print(CONFIG["volme"])
CONFIG["volume"] = "9"
print(describe(bad))


$ python -m mypy config_broken.py
config_broken.py:3: error: Incompatible types (expression has type "str",
    TypedDict item "volume" has type "int")  [typeddict-item]
config_broken.py:4: error: Missing key "device" for TypedDict "Config"
    [typeddict-item]
config_broken.py:5: error: Missing key "shuffle" for TypedDict "Config"
    [typeddict-item]
config_broken.py:5: error: Extra key "shufle" for TypedDict "Config"
    [typeddict-unknown-key]
config_broken.py:7: error: TypedDict "Config" has no key "volme"
    [typeddict-item]
config_broken.py:7: note: Did you mean "volume"?
config_broken.py:8: error: Value of "volume" has incompatible type "str";
    expected "int"  [typeddict-item]
Found 6 errors in 1 file (checked 1 source file)


# The answers.
#
# 1. device: str | None  vs  theme: NotRequired[str]
#    Two different questions. `device` is always PRESENT and may hold None --
#    every reader must handle the empty case. `theme` may be ABSENT: the key
#    might not exist at all, so `cfg["theme"]` is what has to be guarded, with
#    cfg.get("theme") or an "in" check. Marking device NotRequired would be
#    wrong: it is always there. Marking theme  str | None  would be wrong too:
#    it would force every config to carry a theme key holding None.
#
# 2. The narrowing in describe() is the Section 3 machinery, applied to a
#    dict value. After `if cfg["device"] is not None:`, mypy holds cfg["device"]
#    as plain str on that edge -- which is exactly what `" -> " + ...` needs.
#    The sentinel check and the type proof are the same three words.
#
# 3. Which would a test have caught, and when?
#    The typo on line 5 and the misread on line 7 produce KeyError only if
#    that key is ever touched at runtime -- so a test catches them only if it
#    exercises that branch, on that config, in that environment. The "9" on
#    line 3 catches nothing at all: it prints happily, exactly as the original
#    program did, and surfaces months later as `TypeError: '>' not supported
#    between instances of 'str' and 'int'` inside some volume comparison.
#    mypy found all six in 200 milliseconds without running a line, and the
#    "Did you mean volume?" note is the giveaway: it is not grepping for
#    suspicious text, it holds the actual set of keys this dict is allowed
#    to have and compared yours against it.
NOMINAL · is-a (ancestry)Duck.quack()Robot.quack()def f(x: Duck)has quack() but no Duck ancestry — rejectedSTRUCTURAL · has-a (Protocol)Quacker{ quack() }Duck.quack()Robot.quack()both HAVE quack() — both accepted
Fig — Nominal typing demands ancestry (is-a Duck); a Protocol demands only shape — anything with quack() fits the keyhole, regardless of lineage.
THE ONE IDEA TO CARRY FORWARD
Python's runtime was always structural — it calls the slot it finds and never checks ancestry. Nominal hints (x: SomeBaseClass) contradicted that; Protocol repairs it, letting the checker accept "anything shaped like this." Use a Protocol whenever you'd otherwise force a base class purely to satisfy the type checker — you keep duck typing's freedom and get it verified.
Nominal (ABC / base class)
Belongs by ancestry: you inherit or register. isinstance checks the class tree. Explicit, closed — the base must know about you.
Structural (Protocol)
Belongs by shape: you just have the members. Checked statically by signature match. Open — the Protocol needn't know your class exists, and your class needn't know the Protocol's.
Wait — why not just make everything a @runtime_checkable Protocol and isinstance your way to safety? Because that shallow name-only check gives you false confidence exactly where it hurts. It confirms a read attribute exists, then you call read(1024) and discover it took no arguments, or returned an int instead of bytes. The static check would have caught the signature mismatch before you shipped. Runtime isinstance on a Protocol answers "has the names"; only the checker answers "has the right shapes." Lean on the checker for the guarantee; use runtime Protocols sparingly and know their check is thin.

You now hold power tools — unions, generics, protocols, narrowing. The temptation is to annotate everything. Where do hints actually buy safety, and where are they noise? Hint the seams. →

07Hint the seams, infer the rest

Now the overcorrection, because you can feel it coming. You have generics, unions, protocols, narrowing, and the instinct is to annotate everything, stapling : int onto every throwaway loop counter until readable code turns into a thicket of ceremony. That is not rigor — it's noise, and it buries the hints that actually matter under the ones that don't. You need a defensible policy: where do annotations buy safety, and where would the checker have known anyway? The answer isn't taste but structure, and it names the thing this entire volume has been about: the seams.

Start from what the checker already knows for free. Inference is strong within a function body, because there it can see every assignment. count = 0 needs no : int — the checker read the literal and knows. names = [] and total = a + b are all inferred with full confidence from their initializers. Inside a single function, the checker is watching every line, so it needs no help. Then ask the sharp question: where does inference go blind? It goes blind exactly at the boundaries it cannot see across. A function parameter has no initializer the callee can inspect — the value arrives from an unknown caller the checker isn't currently reading. A return type is what every caller must trust without reading the body. A dataclass field is the shape other code constructs and reads across the codebase. A module-level export is what other files consume sight-unseen. These boundaries — signatures, fields, exports — are the seams, and a hint at a seam is a contract.

SYNTAX · Any, object, and where a hint is actually worth typingthe escape hatch that switches checking off, the honest 'unknown', and the runtime dict nobody reads
from typing import Any def load(p: str) -> Any: -- "trust me". checking OFF for everything downstream. def load(p: str) -> object: -- "unknown". checking stays ON; you must narrow first. def load(p: str) -> dict[str, object]: -- what you usually actually meant -- WHERE HINTS PAY WHERE THEY ARE NOISE -- a function others call a 20-line script you delete tomorrow -- anything that can return None count = 0 (already obviously int) -- data from json / csv / the net rows = [] ...unless it starts empty -- the boundary between two modules a 2-line private helper -- a signature you will forget a lambda inside a comprehension f.__annotations__ -- the runtime dict. YOU can read it; nothing else does. typing.get_type_hints(f) -- the same, with string hints resolved back to objects
AnyCompatible with everything in both directions. It does not mean unknown; it means stop checking, and it spreads.
objectThe honest unknown. Every value is an object, but you can do almost nothing with one until you narrow it.
the seam ruleAnnotate parameters and returns; let inference do the insides. A hint at a seam lets both sides be checked independently.
the empty literalThe one body hint worth writing: rows: list[Track] = []. Inference has nothing to learn from an empty list.
the noise testIf the line above already says the type — count = 0, name = "x" — a hint adds characters and no information.
__annotations__A plain dict living on the function, class or module. Frameworks read it; the interpreter never does.
get_type_hintsResolves string annotations back into real objects. Reach for it when from __future__ import annotations is in play.
you type
# gradual.py -- Any is a hole you can see through; object is a door you must open
from typing import Any

SOURCE: str = "gradual.py"


def load_any() -> Any:                  # "trust me" -- checking switches OFF
    return {"title": "Levels", "seconds": 340}


def load_obj() -> object:               # "unknown" -- checking stays ON
    return {"title": "Levels", "seconds": 340}


a = load_any()
o = load_obj()

print("Any, indexed     :", a["title"])
print("object, indexed  :", o["title"])     # checker: ERROR. runtime: fine.
print("hints on load_any:", load_any.__annotations__)
print("hints on load_obj:", load_obj.__annotations__)
print("module hints     :", __annotations__)
try:
    a.whatever_i_like                       # checker: SILENT
except AttributeError as exc:
    print("Any let it past  :", exc)


# ---------- the terminal ----------
$ python gradual.py
$ python -m mypy gradual.py
you see
$ python gradual.py
Any, indexed     : Levels
object, indexed  : Levels
hints on load_any: {'return': typing.Any}
hints on load_obj: {'return': <class 'object'>}
module hints     : {'SOURCE': <class 'str'>}
Any let it past  : 'dict' object has no attribute 'whatever_i_like'

$ python -m mypy gradual.py
gradual.py:19: error: Value of type "object" is not indexable  [index]
Found 1 error in 1 file (checked 1 source file)
where beginners trip
  • Read the two verdicts together. The object line is flagged and works; the Any nonsense on line 24 is waved through and crashes.
  • Any is not a promise, it is a surrender. Every value that flows out of an Any carries the silence with it.
  • Reach for object when you honestly do not know. It costs you one isinstance and buys back every check downstream.
  • Do not annotate self, and do not annotate a return you can see two lines below. Hints at the edges, inference inside.
  • An unannotated def is a checking hole, not a neutral choice. Run --disallow-untyped-defs once on an old module to see how big.
  • Nothing reads __annotations__ unless you tell it to. @dataclass, pydantic and attrs are libraries choosing to look.

Why a contract and not just an annotation? Because a hint at a seam lets the checker verify the two sides independently. The callee is checked against its own body, every caller is checked against the signature, and neither has to re-derive the whole program. That is precisely the modularity that lets a compiler check files separately. You state the interface once, and both sides are held to it in isolation. So the policy falls out cleanly: hint every signature (params and return), every dataclass field, every module boundary; let inference handle short-lived locals. Hint the arrows between the boxes. Leave the insides to the checker.

seams.pypython
def total_seconds(tracks: dict[str, int]) -> int:   # SEAM: hint params + return
    running = 0                    # local: inferred int, no hint needed
    for secs in tracks.values():   # local: inferred int, no hint needed
        running += secs
    return running

def total_seconds(tracks):        # NO seam hints -> to mypy, an unchecked hole:
    running = 0                    # body skipped by default (untyped def)
    return running

And here the failure modes get teeth. First: an unannotated function is, to many checkers, invisible. By default mypy does not type-check the body of a function with no annotations, so a missing signature silently switches checking off for that whole region. (--check-untyped-defs makes it check those bodies anyway. --disallow-untyped-defs makes it complain about the missing hints in the first place.) So the second total_seconds above isn't merely under-documented — it's a hole where bugs pass unexamined. Second: Any is the escape hatch that poisons. Any is compatible with everything in both directions — it accepts anything and is accepted as anything. So a single Any at a seam propagates downstream and erases checking everywhere the value flows:

any_poison.pypython
from typing import Any

def load() -> Any:              # the poison enters at the seam
    return {"secs": 200}

x = load()
x.no_such_method()          # checker: silent. runtime: AttributeError

def load2() -> object:          # 'unknown' done right
    return {"secs": 200}

y = load2()
y.no_such_method()          # checker: ERROR — object has no such method; narrow first

When you mean "I don't know the type," reach for object, not Any. object is the safe top of the hierarchy. It accepts any value but demands you narrow before you use it, so it preserves checking instead of dissolving it. Any says "stop checking." object says "prove what it is first." And third, the softest but most durable point: at a seam, the signature is the interface. It's the contract readers and tools consume, the living documentation that can't drift out of date because the checker enforces it. This echoes the thread from Vol 1's very first object header: the type is metadata the system reads. A local's type is read by the checker for a few lines and forgotten. A seam's type is read by every caller, every reader, and every tool, forever.

TYPE THIS · the chapter's capstone, typed straight throughannotate_the_jukebox.py — our Ch 19 module before and after, and the two mypy runs that separate them
# ================= BEFORE: before/tracks.py =================
"""tracks.py -- the jukebox module from Ch 19 and 21. Not one annotation."""

LIBRARY = [("Blinding Lights", 200), ("Titanium", 245), ("Levels", 340)]


def as_clock(seconds):
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def total_seconds(library):
    return sum(secs for _title, secs in library)


def find(title, library):
    for row in library:
        if row[0] == title:
            return row
    return None


def summary(library):
    return f"{len(library)} tracks, {as_clock(total_seconds(library))}"


# ================= BEFORE: before/report.py =================
"""report.py -- the caller, with two bugs planted in plain sight."""
from tracks import LIBRARY, as_clock, find, summary

print(summary(LIBRARY))

typed_in = "245"                        # BUG 1: came off input(), still a str
print(as_clock(typed_in))

missing = find("Nope", LIBRARY)         # BUG 2: a miss returns None
print(missing[0])


# ================= AFTER: after/tracks.py =================
"""tracks.py -- the same module, annotated at every seam."""

Track = tuple[str, int]                 # one alias: a row is (title, seconds)

LIBRARY: list[Track] = [
    ("Blinding Lights", 200),
    ("Titanium", 245),
    ("Levels", 340),
]


def as_clock(seconds: int) -> str:
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def total_seconds(library: list[Track]) -> int:
    return sum(secs for _title, secs in library)


def find(title: str, library: list[Track]) -> Track | None:
    for row in library:
        if row[0] == title:
            return row
    return None


def summary(library: list[Track]) -> str:
    return f"{len(library)} tracks, {as_clock(total_seconds(library))}"


# after/report.py is byte-for-byte the BEFORE one. Do not change it yet.


# ================= THE FIX: fixed/report.py =================
"""report.py -- both bugs fixed the way the types asked for."""
from tracks import LIBRARY, as_clock, find, summary

print(summary(LIBRARY))

typed_in = "245"
print(as_clock(int(typed_in)))          # FIX 1: convert at the seam

missing = find("Nope", LIBRARY)
if missing is None:                     # FIX 2: the guard the type demanded
    print("not in the library")
else:
    print(missing[0])


# ---------- the terminal ----------
$ cd before && python -m mypy tracks.py report.py
$ python report.py
$ cd ../after && python -m mypy tracks.py report.py
$ cd ../fixed && python -m mypy tracks.py report.py
$ python report.py
$ cd before && python -m mypy tracks.py report.py
Success: no issues found in 2 source files

$ python report.py
3 tracks, 13:05
Traceback (most recent call last):
  File "C:\tmp\ch23lang\before\report.py", line 7, in <module>
    print(as_clock(typed_in))
          ^^^^^^^^^^^^^^^^^^
  File "C:\tmp\ch23lang\before\tracks.py", line 7, in as_clock
    minutes, secs = divmod(seconds, 60)
                    ^^^^^^^^^^^^^^^^^^^
TypeError: unsupported operand type(s) for divmod(): 'str' and 'int'

$ cd ../after && python -m mypy tracks.py report.py
report.py:7: error: Argument 1 to "as_clock" has incompatible type "str";
    expected "int"  [arg-type]
report.py:10: error: Value of type "tuple[str, int] | None" is not
    indexable  [index]
Found 2 errors in 1 file (checked 2 source files)

$ cd ../fixed && python -m mypy tracks.py report.py
Success: no issues found in 2 source files

$ python report.py
3 tracks, 13:05
4:05
not in the library
Build all three folders and run the four commands in order, because the story is in the contrast, not in any one run. Start with the first line of output and sit with it: Success: no issues found in 2 source files, on a program carrying two live bugs. That is not mypy failing. By default it does not check the body of a function that has no annotations, so an unannotated module is not trusted — it is unread. The next command proves the bugs were always there: the same files, run, crash on line 7. Now the annotations. Notice how few there are. One alias, six signatures, one variable, and not a single hint inside a body — minutes, secs and the comprehension variables were always inferable and still are. Yet those few characters were enough to find both bugs before the program ran, and to name them by line. Look closely at what each one caught. Line 7 is a value that came from outside: text off input(), a field from JSON, a column from CSV. Every boundary Volume 2 has taught you hands over str, and as_clock wanted int — the crash lived at the seam between them, which is exactly where a hint pays. Line 10 is different and quieter. find genuinely returns None when a title misses, and -> Track | None simply wrote that down. No new behaviour, no new safety, just a fact that used to live in the reader's head moved into the signature where a machine can enforce it. Then read the two fixes. One is a conversion at the boundary, int(typed_in); one is a guard, if missing is None. Neither is a cast, and neither silences anything — the types told you what the program was missing and you added it. Two things to try. First, delete only the -> Track | None from find, leaving its parameters annotated, and re-run mypy on after/: one error vanishes, because a missing return hint is inferred and the None case with it — then delete the parameter hints too and watch the function go dark. Second, run python -m mypy --disallow-untyped-defs against before/. Four errors, one per unannotated def, and each one is the checker telling you where it had been told not to look.

That closes the arc. Volume 2 has been the physics of boundaries — the disk, other encodings, other people's code, future-you, other cores, other processes — and the one law under all of them: what crosses a boundary is bytes and contracts, never the live thing itself. Types matter most at exactly those boundaries. That is where the object leaves the reach of the code that made it and arrives somewhere that must trust a promise. Hint the seams — the arrows between functions and files — and let inference handle the insides. The seams are what this book has been about since the pointer in the object header.

orders.pyreport.pyfetch()rows = query()n = len(rows)inferred-> list[Row]clean()good = [...]inferredrows: list[Row]summarize()total = sum(...)inferredno contractrender()x: Anychecker blind — Any fogcontract you write, on the arrowsgrey = inferred free · Any = unchecked
Fig — Hint the arrows, infer the insides: contracts on the calls between functions are what you write; one untyped signature lets Any fog seep in, unchecked.
THE ONE IDEA TO CARRY FORWARD
Inference is strong inside a function and blind at its edges. So annotate the edges — parameters, returns, dataclass fields, module exports — and let the checker infer the locals. A hint at a seam is a contract that lets both sides be verified independently; a hint on a throwaway local is noise. And guard the seams from Any: it doesn't mean "unknown," it means "stop checking here and everywhere downstream." When you mean unknown, write object and narrow.
Wait — if untyped functions are skipped by default, could a whole legacy module be silently unchecked while mypy reports "Success: no issues"? Yes — and it's a classic false green. A file full of unannotated defs can pass cleanly because mypy never looked inside any of them; it only checks bodies it's invited into by a signature. That's why gradual typing starts at the seams: annotate the signatures first, and the bodies light up for checking. "No issues" means "no issues in the parts I was allowed to read" — and until you hint the arrows, that can be almost nothing.

You've reached the seam between Python's two type systems and learned to stand on the right side of it. The runtime carries the object; the checker carries the contract; and the whole discipline is knowing which line reads which. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 23, in working code

In Python a variable's type is a fact about the live object, discovered at runtime; a type hint is a note attached to the side that the interpreter never reads. These programs pull those two apart so you can see the hint stay frozen while the object's real type moves — then walk up through unions, generics, protocols, and the Any escape hatch, always showing what the machine actually does versus what a checker would merely claim.

Annotations are inert side-data
A type hint is stored as data (in __annotations__) and is never consulted while your code runs. The object's real type can change out from under a hint and nothing errors.
Unions, None, and narrowing
str | None and int | str are real runtime objects. A guard (is None / isinstance) narrows the possibilities so a later method call is safe — and skipping the guard reproduces the classic 'NoneType has no attribute' crash.
Generics — one body, many types
TypeVars and parametrized aliases (list[T], dict[str, int], class Box[T]) let one definition serve every element type for the checker — while at runtime the parameters are erased and only the plain container remains.
Structure and the escape hatch
Protocols match on shape (does it have quack()?) rather than ancestry; @dataclass turns field hints into a real class; and Any switches the checker off — leaving the runtime to enforce nothing extra.
end of chapter 23 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked