Advanced
Type Hints & Static Typing
Type hints annotate code with the types it expects, documenting intent and letting tools like mypy catch type bugs before runtime — but Python ignores them while running, so they never enforce anything by themselves. Annotations, the runtime no-op, collection and optional types, introspection, and Protocols.
Suggest an editType Hints & Static Typing — Documentation the Tools Can Check
Python is dynamically typed, but you can annotate code with the types it expects. The thesis to hold firmly: type hints are checked by external tools (a type checker like mypy), not by Python at runtime — the interpreter parses them, stores them, and otherwise ignores them. So hints buy you documentation, editor autocomplete, and a static safety net, but they never change what the program does.
💡 The core idea.
- Type hints are checked by external tools like
mypy. - Python ignores them at runtime — parses, stores, then moves on.
- They buy documentation, autocomplete, and a static safety net.
- They never change what the program does.
This builds on every type you've met and on the object model. Every runnable output below was produced by running the code; the one mypy example is marked, since the runner has no type checker installed.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the interpreter is actually doing.
- A concrete bite — a specific, runnable way the naive assumption fails.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of Contents
- Annotating functions and variables
- Hints are not enforced at runtime
- Collection and optional types
- Hints are introspectable
- Protocols and static checking
- Mental-model summary
- Gotcha checklist
1. Annotating functions and variables
A function annotation puts : type after each parameter and -> type before the colon. A variable annotation is name: type = value.
Output:
Hi Ada! Hi Ada!
30Analysis. name: str, times: int = 1, and -> str declare the expected types; age: int = 30 annotates a variable. The function ran exactly as an un-annotated one would — the hints added information for readers and tools without changing behaviour. They're documentation that lives in the code and stays in sync with it.
Intuition.
Mechanism. When Python compiles a def, it records the annotations (in __annotations__, §4) and otherwise treats them as inert — they don't wrap, check, or convert anything. A variable annotation with no value (x: int) doesn't even create the variable; it only registers the intended type.
Concrete bite. A variable annotation with no value doesn't even create the variable — it only records the intended type:
Traceback (most recent call last):
File "/w/main.py", line 2, in <module>
print(x)
^
NameError: name 'x' is not definedx: int registered an annotation but bound nothing, so print(x) is a NameError. The annotation is metadata, not an assignment — proof that the interpreter treats it as inert.
💡 Earned rule. Annotate function signatures and tricky variables to document intent and unlock tooling; skip obvious locals (count = 0 needs no : int). The cost is essentially nil at runtime, and the payoff is real only if you actually run a checker (§5) — hints you never check are just comments that look official.
2. Hints are not enforced at runtime
This is the point people most often get wrong. A hint is not a runtime check — passing the wrong type runs anyway, with whatever behaviour that type produces.
Output:
ababAnalysis. double is annotated for int, but we passed "ab". Python did not raise — it ran "ab" * 2, which is string repetition, giving "abab". The -> int return hint is equally ignored; the function returned a str. The annotations were pure documentation; the runtime did exactly what the values' real types dictate.
Intuition.
Mechanism. At call time Python binds arguments to parameters without consulting annotations at all. n * 2 dispatches on the actual type of n (str → repeat), not the hinted type. There is no hidden isinstance check anywhere.
Concrete bite. The output is the bite: a function "typed" for int happily returned "abab" from a str input. If you rely on hints to guarantee types, you'll be surprised — the guarantee doesn't exist at runtime. Only a separate static check (or an explicit isinstance/validation you write) catches it.
💡 Earned rule. Treat hints as checked documentation, not runtime contracts: to actually catch type errors, run a static checker like mypy (§5) in development/CI, or validate explicitly when you must enforce types at runtime (e.g. at a trust boundary). The cost of forgetting this is false confidence — annotated code is not validated code unless something checks it.
3. Collection and optional types
Annotate containers with their element types using built-in generics: list[int], dict[str, int], tuple[int, ...]. For "a value or None," use X | None.
Output:
6
1
NoneAnalysis. list[int] documents "a list of ints"; dict[str, int] a string→int mapping. int | None (the modern spelling of Optional[int]) says the result is an int or None — exactly what dict.get returns on a hit or miss (Tutorial 13). These are still just annotations — total would run on a list of strings — but they tell a checker and a reader the precise shape.
Intuition.
Mechanism. list[int] is a parameterized generic: the element type lives in the brackets. X | None is a union of two types. A checker reads these to verify call sites; at runtime they're inert annotations like any other (the brackets do construct a small generic-alias object, but it isn't used to validate).
Concrete bite. The element type is documentation, not a gate — pass the wrong element type and it runs until the values themselves clash:
Traceback (most recent call last):
File "/w/main.py", line 3, in <module>
print(total(["a", "b"])) # hint says int; runtime does not check
~~~~~^^^^^^^^^^^^
File "/w/main.py", line 2, in total
return sum(nums)
TypeError: unsupported operand type(s) for +: 'int' and 'str'list[int] didn't reject the list of strings; the program ran into sum and failed there (sum starts at 0, and 0 + "a" is the error). A checker would have flagged the call site; the runtime only fails once the values actually clash. The real payoff of | None is the same idea — a checker forces you to handle the None that find can return, before it becomes a runtime AttributeError.
💡 Earned rule. Annotate collection element types and use X | None for maybe-absent values — it documents the shape and makes the checker enforce None-handling. The cost is a little verbosity; the payoff is the most common real bug class (forgetting something can be None) caught statically rather than at 3 a.m.
4. Hints are introspectable
Annotations are stored on the object in __annotations__, so tools (and you) can read them at runtime — the one place hints are visible to running code.
Output:
{'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'bool'>}Analysis. f.__annotations__ is a dict mapping each parameter (and 'return') to its annotated type. This is how frameworks like dataclasses, pydantic, and attrs do use hints at runtime — they read __annotations__ and build behaviour from it. The hints themselves still don't check anything; libraries opt in to using them.
Intuition.
Mechanism. The compiler collects annotations into the __annotations__ dict on the function (or class/module). Anything that wants to act on hints reads that dict explicitly — there's no automatic enforcement, just available metadata.
Concrete bite. Only signature (and class/module) annotations are recorded — annotations on local variables inside a function body are stored nowhere:
{}f.__annotations__ is empty: the y: int inside the body left no trace. Python keeps annotations that describe an interface (parameters, return, class fields) and discards purely local ones — which is exactly why @dataclass (Tutorial 27) can read a class's annotated fields to build its __init__, but no library can act on a function-local hint.
💡 Earned rule. Lean on annotation-reading libraries (dataclasses, pydantic) when you want runtime structure from types — they turn hints into validation/construction for you. The cost/boundary: that behaviour comes from the library, not the language, so it applies only where you opt in; plain annotated code remains unchecked.
5. Protocols and static checking
typing.Protocol expresses structural typing ("duck typing, formalized"): a type matches if it has the right methods, regardless of inheritance. And a checker like mypy is what turns all these hints into actual error-catching.
Output:
has 3 items
has 5 itemsAnalysis. Sized is a Protocol requiring a __len__. describe accepts "anything with __len__," so a list and a string both qualify — without either inheriting from Sized. This is structural typing: the shape matters, not the family tree. A checker verifies the argument has __len__; at runtime, nothing is checked.
Intuition.
Mechanism. A Protocol describes a shape (a set of methods/attributes). mypy accepts any type whose shape matches, mirroring how Python actually works (duck typing). But, like all hints, it's not enforced at runtime — pass something without __len__ and you get the ordinary error.
Concrete bite. At runtime the Protocol provides no protection; you get the underlying failure:
Traceback (most recent call last):
File "/w/main.py", line 6, in <module>
print(describe(42)) # int has no __len__ - hints do not stop this at runtime
~~~~~~~~^^^^
File "/w/main.py", line 5, in describe
return f"has {len(x)} items"
~~~^^^
TypeError: object of type 'int' has no len()describe(42) violates the Protocol, but Python runs it anyway and fails inside len(42). A static checker is what would have caught describe(42) before running — that's the whole point of typing. Running mypy on this file would report (illustrative — mypy isn't installed on this runner):
error: Argument 1 to "describe" has incompatible type "int"; expected "Sized" [arg-type]💡 Earned rule. Use Protocol to type "anything shaped like this" (the Pythonic alternative to forcing inheritance), and run mypy (or pyright) in CI to convert the runtime TypeError above into a pre-run error. The cost is adopting a checker and keeping hints honest; the payoff is a large class of bugs caught statically — but only if you run the checker, since Python itself never will.
6. Mental-model summary
| Principle | Consequence |
|---|---|
| Hints annotate types but are not checked at runtime | double("ab") returns "abab" despite an int hint |
list[int], dict[str,int], X | None annotate shape |
Documentation + checker enforcement; still inert at runtime |
Annotations live in __annotations__ |
Libraries (dataclasses, pydantic) read them to build behaviour |
Protocol = structural typing |
Matches by shape (has __len__), not by inheritance |
A static checker (mypy) is what catches type errors |
Run it in dev/CI; the interpreter never enforces hints |
7. Gotcha checklist
- A wrong type "passed" despite hints → hints aren't runtime checks; run
mypy, or validate explicitly at trust boundaries. AttributeError: 'NoneType' ...→ anX | Nonevalue wasn't handled; check forNone(a checker would have flagged it).- Hints feel pointless → they only pay off with a checker and an editor; add
mypyto CI. - Wanted runtime validation from types → plain hints won't; use
dataclasses/pydantic, which read__annotations__. - Forcing inheritance just to satisfy a type → use a
Protocoland type by shape instead.
🧪 Predict, then check. Annotate a function def clamp(x: int, lo: int, hi: int) -> int: that returns x bounded to [lo, hi]. Predict what clamp(5, 0, 10) returns — and what clamp("z", "a", "m") does at runtime (does the hint stop it?). Then predict clamp.__annotations__. The middle one is the lesson: hints describe intent, the runtime obeys the actual types.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.