Generics and Protocols: PEP 695 syntax, variance without hand-waving, structural typing
PEP 695 makes generics first-class: class Stack[T] without TypeVar ceremony. Variance explains why list[Dog] is not list[Animal] but Sequence[Dog] is a Sequence[Animal]. Protocol brings Go-style structural typing; runtime_checkable checks names only. overload shapes APIs.
The checker flagged it plainly: Argument 1 has incompatible type "list[Dog]"; expected "list[Animal]". The author was sure this was a checker bug — a Dog is an Animal, any OO course says so — so he reached for the universal solvent: cast(list[Animal], dogs). Review approved it; the build went green. Three sprints later someone extended the shared helper to append a Cat sentinel to the list it received — perfectly legal for a list[Animal]. The original caller’s “list of dogs” now contained a cat, and the nightly job that called .bark() on every element died at item 3,114 with AttributeError, four hours into a five-hour batch. The checker had refused exactly this scenario: a writable container of Dogs is not a container of Animals, because anyone holding the broader view can put a non-Dog in. The cast did not fix a false positive. It deleted the proof.
From TypeVar ceremony to class Stack[T]
If you’ve ever opened a library’s source and seen five module-level TypeVar declarations before a single line of business logic, you know the tax. PEP 695 eliminates most of it.
For a decade Python generics meant declaring a module-level TypeVar, threading it through Generic[T], and remembering which of your seventeen Ts was bound to what. PEP 695 (Python 3.12, 2023) folds all of it into the definition site:
# Old ceremony (still valid, still everywhere in legacy code)
from typing import Generic, TypeVar
T = TypeVar("T")
class OldStack(Generic[T]):
def push(self, item: T) -> None: ...
# PEP 695: the type parameter lives where it is used
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
class Repo[M: Model]: # bound: M must subtype Model
def get(self, pk: int) -> M | None: ...
def first[T](items: list[T]) -> T | None: # generic function
return items[0] if items else None
type Result[T] = T | None # generic type aliasThe new syntax is not sugar only: parameters are scoped to the class or function (no leaking module-level T), bounds read inline ([M: Model]), and type aliases are lazily evaluated, which kills a whole family of forward-reference headaches. A checker infers Stack[int] from Stack[int]() and then proves every push/pop against it. The honest tradeoff: PEP 695 requires 3.12+, so libraries supporting older Pythons still ship TypeVar — you must read both dialects even if you write only the new one.
Variance, honestly
Variance answers one question: if Dog subtypes Animal, what is the relationship between list[Dog] and list[Animal]? For mutable containers the answer is none — list is invariant. The Hook is the proof: if list[Dog] were accepted as list[Animal], the callee could legally append(Cat()), corrupting the caller’s list. Read-only views are safe in one direction: Sequence[Dog] is a Sequence[Animal] (covariant), because a type you can only read Dogs from can be safely read as producing Animals. Functions flip the arrow: Callable[[Animal], None] is usable where Callable[[Dog], None] is expected (contravariant in parameters) — a handler that accepts any Animal certainly handles Dogs. The practical rules that fall out: accept Sequence/Iterable/Mapping in signatures unless you genuinely mutate, and the invariance error disappears for free; return concrete types; never “fix” a variance error with cast — the checker is describing a real aliasing hazard, and the fix is choosing a read-only type or copying the list.
def tag_all(animals: list[Animal]) -> None rejects a list[Dog] argument. What is the smallest correct fix?
Protocol: structural typing, and where runtime_checkable lies
When you design a typed API for a function that only needs to emit metrics or close a resource, why should the caller’s class inherit from some base you control? Protocol answers: it shouldn’t. Any class with the right methods qualifies — including test fakes your teammates write without ever importing your module.
Protocol (PEP 544) is Python’s answer to Go interfaces: conformance by shape, not by inheritance. The consumer declares what it needs; any class with matching methods satisfies it, with zero imports between the two sides:
from typing import Protocol, overload, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class MetricsSink(Protocol):
def emit(self, name: str, value: float) -> None: ...
def flush_all(sinks: list[MetricsSink]) -> None:
for s in sinks:
s.emit("flush", 1.0) # statsd, datadog, a test fake — all conform
class Config:
@overload
def get(self, key: str) -> str | None: ...
@overload
def get(self, key: str, default: str) -> str: ...
def get(self, key, default=None):
return self._data.get(key, default)This is how you design typed APIs at senior level: depend on the narrowest protocol the function needs (Closeable, not IOBase), and test fakes conform automatically — no mock inheritance gymnastics. overload completes the toolkit: Config.get returns str | None without a default but a guaranteed str with one, so callers do not write dead None-checks. Now the limits, because they bite: @runtime_checkable makes isinstance(x, Closeable) work, but the check verifies method existence only — a close(self, force: bool) with an incompatible signature still passes isinstance and explodes when called; data-member protocols and non-method details are not checked at runtime either. isinstance against a protocol is also measurably slow (it introspects attributes per call) — fine at a boundary, wrong in a hot loop. Static checking verifies full signatures; the runtime check is a name-presence sniff. Treat runtime_checkable as a boundary heuristic, never as validation.
isinstance(obj, SomeRuntimeCheckableProtocol) returns True, yet calling the protocol method on obj raises TypeError. How?
- 01Explain variance through the list[Dog] vs list[Animal] case: why is the checker right to reject it, and what are the two correct fixes?
- 02What does Protocol give you that ABCs do not, and exactly what does @runtime_checkable verify at isinstance time?
PEP 695 (Python 3.12) made generics first-class syntax: class Stack[T] and def first[T](...) scope the parameter to the definition, bounds read inline as [M: Model], and type Result[T] = T | None aliases evaluate lazily — though legacy TypeVar ceremony remains in any library supporting older Pythons, so both dialects must be readable. Variance is the part most engineers hand-wave and the checker does not: writable containers are invariant — list[Dog] is not list[Animal], because the broader view legalizes appending a Cat into a list of dogs, which is precisely the runtime crash from the Hook after a cast silenced the warning. Read-only types are covariant (Sequence[Dog] is a Sequence[Animal]), and callables are contravariant in parameters (an Animal-handler serves where a Dog-handler is expected). The design rules that follow: accept the widest read-only type you can (Sequence, Iterable, Mapping), return concrete types, and treat a variance error as a real aliasing hazard — fix the signature, never cast. Protocol brings structural typing: consumers declare the shape they need, producers conform without imports or inheritance, and test fakes come free. Its runtime escape hatch, @runtime_checkable, verifies method names only — signatures are never compared at isinstance time and the check is slow — so it is a boundary heuristic, while overload lets one method present precise types per call pattern. Now when you see a variance error from the checker, your first instinct should be to reach for Sequence instead of cast — and when you need to stub out a dependency in a test, reach for Protocol instead of inheriting from production code.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.