python/volume-2/chapter-23ch 23 / 64 · 23 min

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. Now we turn to 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, and concluded that the : int is what made it an int. It didn't. 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 reasons about your code without running a single line. All the way through, we keep asking the one question that clears up 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 the seam is plain: two mechanisms wearing 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.

Two type systems, coexisting A checker reasons without running code Narrowing: how control flow teaches the checker Optional and Union: a name with several types
scroll to begin

01Two type systems, coexisting

Let's start at the prompt, because the whole chapter opens up in three lines. Type age: int = 5. Then type age = 'oops' — no error, no complaint, nothing. 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 type error, a refusal, something. It wasn't even noticed. So the : int didn't make anything anything. That leaves a sharp question: type(age) is plainly reading some other thing than the : int you wrote — so what is it reading? That gap is the seam, and we're 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 first useful field in that header is ob_type — 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), a single dereference of that header field. It isn't a search, a comparison, or a lookup by name. It's an O(1) read of a pointer that travels with the object. 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. At module or class scope, that annotation gets filed as a key/value pair into a real, inspectable dictionary named __annotations__. Watch the bytecode prove the two 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, which 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's 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.

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, read by nobody at runtime and by a separate checker before runtime. 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.

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've run mypy and watched it print errors, and it probably felt like a spellchecker — a linter grepping your text for suspicious shapes. It's doing something far stronger, 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, and from that reading it builds 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, and it walks your statements one at a time, changing 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, keeping 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 — 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 asked a question it can't answer by introspection — what does int + float return? — and looked the answer up in typeshed. Typeshed is the corpus of .pyi stub files: 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 — a fact derived purely from reading two files, never from running one line. The last line proves 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, because it reasons about the program text, which contains all branches, rather than one execution, which contains one path. 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, in reality, is 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 isn't one checker but several. mypy and pyright are independent implementations of roughly the same PEP 484 rules, reading 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.

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's the moment that first makes the checker feel like it's reading your mind. You have a name that might be None, and 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 isn't one fixed type per name. It's a type per program point. As the checker walks the control-flow graph, the type it holds for x depends on where in the code you are, and 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 — because if x isn't 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 can't 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, with one axis swapped from time to place.

The recognized guard forms are a specific, learnable vocabulary. This isn't fuzzy pattern-matching; it's a fixed list. x is None / 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 with a TypeGuard (PEP 647) or TypeIs (PEP 742) function — 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.

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. If 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 isn't the tool being fussy; it's 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 isn't an exotic bug; by volume it's 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 aren't decoration. They're the checker's way of forcing you to face the None case at the exact seam where it's born, turning 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's 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 can't 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's 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 exactly the narrowing of the previous section. So Optional mechanically compels a None-check before use. It isn't asking politely; the type algebra leaves you no other door.

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 — 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's 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, and the type system 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've 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 — which is 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 those spots 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], sets it against the parameter's declared type, list[T], and 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, with the machinery being type inference by unification. It's 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, because 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's the entire reason a value can be fetched from deep inside a collection and still arrive with a known type.

And now the metal-up truth that keeps you honest, because it's 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, and type([1, 2, 3]) is 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, no Generic[T] base. Same unification underneath, less ceremony on top.

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.
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__, and 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 — the default in Java, C++, and Python's own isinstance-against-a-base — says X is a subtype of Y iff X explicitly inherits from Y. It's about declared ancestry. Structural subtyping says X is a subtype of Y iff X has the members Y requires — it's about shape, and it 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__(). The interpreter checks for the slot on the object's type, never for a base class. The runtime has always been structural. 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, because 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're no longer choosing between duck typing and type checking; you're type-checking duck typing.

Now the honest caveat, and it matters. Protocols are checked structurally at static time. At runtime they normally do nothing at all — but mark one @runtime_checkable and you may write isinstance(duck, Quacker). Here's the trap: that runtime isinstance only checks that the attribute names exist. 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__," and Iterable is just "has __iter__." The duck contracts were real; Protocols finally gave them a spelling the static world can verify.

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 isn't 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. It's structural, 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 can't 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.

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's exactly 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, and 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 doesn't type-check the body of a function with no annotations — 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 — 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 then forgotten. A seam's type is read by every caller, every reader, and every tool, forever.

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, because that's 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 filed to the side that the interpreter never reads. These programs pull the two apart so you can watch the hint stay frozen while the object's real type moves. Then they 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.com
Written by Ajai Raj