open atlas
↑ Back to track
Python for JS/TS developers PY · 04 · 02

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.

PY Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

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 alias

The 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 nonelist 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.

Quiz

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.

Quiz

isinstance(obj, SomeRuntimeCheckableProtocol) returns True, yet calling the protocol method on obj raises TypeError. How?

Recall before you leave
  1. 01
    Explain variance through the list[Dog] vs list[Animal] case: why is the checker right to reject it, and what are the two correct fixes?
  2. 02
    What does Protocol give you that ABCs do not, and exactly what does @runtime_checkable verify at isinstance time?
Recap

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.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.