Language Internals
Part 5 of 6 · Python Language ProficiencyTyping — Generics, Protocols, TypedDict, Literal & runtime checks
Generics, Protocols, TypedDict, Literal, and the line between checkers and runtime validation.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Do annotations run on every call?
Answer
No. They are stored and read by checkers and decorators. The interpreter does not enforce them by default.
L2
What does a Protocol check?
Answer
Structural compatibility. Any object with the required members is accepted, without a shared base class.
L3
When is an ABC the better boundary?
Answer
When you own the hierarchy and want nominal identity, registration, or a runtime isinstance that means 'this family'.
L4
Protocol or TypedDict?
Answer
Protocol describes attributes and methods. TypedDict describes required keys on a dict.
L5
What does runtime_checkable actually test?
Answer
Presence of the methods. It does not check argument types, return types, or attributes in the general case.
L6
When do you pick pydantic or msgspec over TypedDict?
Answer
Untrusted input, coercion, nested validation, and serialization. TypedDict types a dict you already trust or that a checker can see.
L7
How do you keep heavy typing imports off the runtime path?
Answer
Quote annotations with from __future__ import annotations, and put imports that exist only for the checker under TYPE_CHECKING.
Failure modes
Hints treated as a parser
The service accepts a payload the checker never saw, and a missing key shows up far from the edge.
runtime_checkable overtrust
isinstance succeeds because close exists, even if close has the wrong signature.
Any and type-ignore debt
The checker is silenced at the exact boundary that needed a model.
Optional forgotten
A value typed as T or None is used as T. The checker would have caught it. The runtime raises later.
Misconceptions
Type hints slow the hot path in a meaningful way.
Checking is offline. Importing a heavy typing stack can cost startup. Guard that with TYPE_CHECKING.
Protocol and ABC are interchangeable.
One is structural. The other is nominal. Mixing them hides whether a stranger's class is supposed to count.
cast validates.
cast is a hint to the checker. It does not look at the value.
Interviewer traps
Saying the type checker enforces production payloads.
Name the checker and the runtime parser as two tools.
Using TypedDict for an object with methods.
TypedDict is a dict shape. A Protocol is the object shape.
Reaching for a nominal hierarchy you do not control.
If the caller owns the class, a Protocol accepts it without inheritance.
Design scenario
Same prompt for every reader.
Requirements
Reject bad JSON before business logic. Keep the closer structural so test fakes need no base class. Exhaust role literals.
Traffic / scale
The validator runs once per request. The closer is called on the way out.
Latency
Validation cost stays on the edge, not inside a per-row loop of casts.
Consistency
A document that fails validation never reaches storage.
Availability
A fake closer in tests is accepted by the checker without importing the framework.
Failure assumptions
- The handler annotates a dict and calls json.loads without a schema.
- A Protocol is runtime_checkable and treated as a full schema.
Constraints
- Stay on typing and validation. Do not design the service mesh.
- Name Protocol, TypedDict or a parser, and Literal.
Prompt
A public HTTP handler accepts a user document and passes it to storage and to a closer that only needs a close method.
Overview
Typing pays off when a checker can see the call. It does not see the bytes on the wire. The senior distinction is which tool owns which bug.
Decisions
- 1
Annotation
- nextmypy or pyright
- 2
mypy or pyright
- nextUntrusted payload?
- ?
Untrusted payload?
- yesParser at the boundary
- noErased at runtime
- 4
Parser at the boundary
- 5
Erased at runtime
Lesson map
Typing — Generics, Protocols, TypedDict, Literal & runtime checks
Generics, Protocols, TypedDict, Literal, and the line between checkers and runtime validation.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB hint["Annotation"] check["mypy or pyright"] edge["Untrusted payload?"] runtime["Parser at the boundary"] hint -->|Annotation to mypy or pyright| check check -->|mypy or pyright to Untrusted payload?| edge edge -->|yes| runtime
| Layer | Tool | What it enforces |
|---|---|---|
| Static | pyright or mypy | Call sites, narrowing, generics |
| Runtime structural | isinstance plus @runtime_checkable | Methods exist, little else |
| Runtime data | pydantic, msgspec, jsonschema | Values at the boundary |
Protocols and ABCs
Press Run. Snippets must be self-contained — no network, files, or native modules.
| Protocol | ABC | |
|---|---|---|
| Matching | Structural | Nominal (inherit or register) |
| Boundary | You do not own the implementer | You own the hierarchy |
isinstance | Only with @runtime_checkable, and only for methods | Yes, for real subclasses and registrations |
@runtime_checkable does not check attribute types. A close that takes extra required arguments can still pass isinstance.
Generics
A Repo[T] keeps add and all on the same T. The checker rejects mixing strings and ints. Python 3.12 can write class Repo[T]: (PEP 695) when that is your baseline. TypeVar remains the portable form.
from typing import Generic, Iterable, TypeVar
T = TypeVar("T")
class Repo(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> None:
self._items.append(item)
def all(self) -> Iterable[T]:
return tuple(self._items)TypedDict, Literal, narrowing
from typing import Literal, NotRequired, TypedDict, assert_never
class UserDTO(TypedDict):
id: str
role: Literal["admin", "user"]
nickname: NotRequired[str]
def handle(cmd: Literal["start", "stop"]) -> None:
if cmd == "start":
return
if cmd == "stop":
return
assert_never(cmd)Use TypedDict for a dict shape the checker can see. Use a dataclass or a schema library when you want behavior, coercion, and rejection of unknown input.
Interview Q&A
Do annotations slow the runtime?
Answer
Not per call in any way that matters. Importing heavy typing helpers can slow startup. from __future__ import annotations and TYPE_CHECKING keep that off the process.
Protocol or TypedDict?
Answer
Protocol is an object with attributes or methods. TypedDict is a dict with named keys. A file-like object is a Protocol. A JSON user document is a TypedDict or, if untrusted, a schema.
When is pydantic the right tool instead of TypedDict?
Answer
Untrusted input, coercion, nested validation, and dumping back to JSON. TypedDict does not parse.
What does cast do?
Answer
It tells the checker to treat a value as a type. The value is unchanged. It is not a validator.
Why can isinstance on a Protocol lie?
Answer
The runtime check looks for attribute names. It does not type-check the callable the way mypy does.
Optional versus T or None?
Answer
Same idea on 3.10 and later: the value might be None, and the checker should force a narrow. Forgetting the check is a runtime AttributeError waiting to happen.
Where do circular typing imports go?
Answer
Under if TYPE_CHECKING. Quote the annotations, or use from __future__ import annotations, so the names are not evaluated at runtime.
What is the one-sentence answer?
Answer
The checker owns call sites. The parser owns bytes you did not type yourself.
Pitfalls
- Bare
liston older interpreters when you meantlist[int]. - A class-level mutable mistaken for a per-instance default. That is a data-model bug the data model page covers. Types do not fix it.
Any, or a type-ignore comment, piled on the boundary.- Treating
@runtime_checkableas a schema. - Using
castwhere a parser belongs.
You have a missing close on a fake, a JSON body with a string where an id should be an int, and a role string outside admin and user. Assign each bug to Protocol, a runtime parser, or Literal. Say which one cast would hide.