Advanced typing: function overloads, TypedDict for structured dicts, variance in generic types, and generic static protocols.
In this chapter you will learn to:
Declare multiple signatures for one function with @overload.
Type a structured dict with TypedDict — and choose between it and @dataclass.
Reason about variance: when Container[Sub] is a Container[Super] (covariant), when it isn’t (invariant), and when the relationship reverses (contravariant).
Read type hints at runtime with typing.get_type_hints.
27.1 Overloaded signatures
Some functions have inputs and outputs that depend on each other. max is the canonical example: with no key, the elements must be LT-comparable and the return is one of them; with a key=Callable[[T], LT], the elements only need whatever the key produces but the return is still a T. A single signature can’t express that — you need one annotation per calling shape. @overload is how you declare them all and still ship a single implementation.
Overloads 1 and 2 cover the positional form (max(a, b, c, ...)). Without key the elements share an LT-comparable type and the return is LT. With key, the elements share an arbitrary T and the key produces LT — but the return is still T, the original element type.
Overloads 3 and 4 are the iterable form (max([1, 2, 3])). Same split: no key forces LT-comparable elements; with a key, the elements are arbitrary T and the return is T.
The leading __ on __arg1 makes those parameters positional-only — preventing callers from writing my_max(__arg1=3, __arg2=4) and aligning the surface with the real max.
The four @overload stubs have empty bodies (...). They’re invisible at runtime; only the final undecorated def actually exists. The overloads are pure documentation for mypy.
The general pattern: write one stub per supported calling shape, then a single concrete implementation. The type checker uses the stubs to pick the right return type at each call site; the runtime ignores them.
27.2TypedDict for structured dicts
When you receive a JSON-like dict whose keys you know, the bare dict[str, Any] annotation throws away everything useful — every key is Any, every value is Any, and the type checker can’t catch typos. TypedDict is the static fix: it documents the exact shape while keeping the runtime as a plain dict.
class BookDict(TypedDict): looks like a class declaration but generates almost nothing at runtime — it’s a marker the type checker reads. The annotated names become required keys with the given types.
book: BookDict = {...} is just a plain dict literal. There’s no BookDict(...) constructor — assignment with the annotation is the whole interface.
mypy checks at static-analysis time that the literal has all four keys with matching types. book["title"] is statically str; book["pagecount"] is statically int. A typo like book["title"] = 5 flags as a type error before the program runs.
At runtime, book is a plain dict — isinstance(book, dict) is True; isinstance(book, BookDict) raises a TypeError because TypedDict isn’t a runtime class.
TypedDict
@dataclass
Runtime type
dict
a real class
__init__, __repr__, __eq__
inherited from dict
generated
Mutation
yes (any key)
yes (depends on frozen)
Use for
parsed JSON / config
domain entities
If you need methods, equality, ordering — use a dataclass. If you’re modeling an external dict you receive (an API response, a JSON config) — use TypedDict.
27.3 Variance
Suppose Dog is a subclass of Animal. Is list[Dog] a list[Animal]?
The answer is no: list is invariant. Why? Because list[Animal] accepts Cat (also an Animal), but list[Dog] does not — appending a Cat to what you thought was a list[Dog] is a type error.
T_co = TypeVar("T_co", covariant=True) is the marker. The _co suffix is convention; what makes it covariant is the keyword argument.
ImmutableBag(Generic[T_co]) ties the class to the type parameter. Once a T_co is fixed by construction (ImmutableBag([Dog()])), every subsequent appearance of T_co refers to that type.
The class only ever producesT_co — it appears in a return position (Iterator[T_co]) and an input position (Iterable[T_co]) that becomes part of its read-only state. There’s no method that accepts a new T_co later, so a tighter type can never enter through the back door.
That’s why ImmutableBag[Dog] can substitute for ImmutableBag[Animal]: any caller expecting to receiveAnimals from the bag will be perfectly happy with Dogs.
The opposite case — contravariance — is for things that only consumeT:
T_contra = TypeVar("T_contra", contravariant=True) is the mirror marker.
drop(self, item: T_contra) is the only operation, and T_contra appears in an input position. The class never produces a T_contra — it only swallows them.
A TrashCan[Animal] accepts any animal, including dogs. So if a function expects a TrashCan[Dog], you can hand it a TrashCan[Animal] and it’ll work — the can will accept anything narrower than what it advertises. The subtype direction reversed.
The rule of thumb:
Container’s role
Variance
TypeVar
Produces T only
covariant
T_co = TypeVar(..., covariant=True)
Consumes T only
contravariant
T_contra = TypeVar(..., contravariant=True)
Both
invariant (default)
T = TypeVar(...)
You don’t need this most days. You’ll need it when you write a generic library and mypy rejects what you thought was an obvious assignment.
27.4 Modern type hints (Python 3.11–3.13)
Three PEPs reshape how you write generics and class hierarchies. They’re strictly additive — the older forms still work — but new code should prefer the new syntax.
27.4.1 PEP 695 generic syntax (3.12)
Before 3.12 you had to declare each type variable as a free-standing TypeVar and then mention it in the function signature — a two-step dance even for one-off generics:
Walking through the old form: TypeVar("T_old") allocates a type variable bound to the string"T_old"; T_old is then a Python object that mypy recognizes as a generic placeholder. You declare it once and reuse it across all signatures that share the parameter.
The 3.12 form drops the boilerplate — type parameters live on the def itself:
def first[T] declares T as a fresh type parameter scoped to this function. No separate TypeVar allocation, no name to keep in sync.
first([1, 2, 3]) resolves T = int; the return type is int. first(["a", "b"]) resolves T = str; same function, different inferred parameter.
The same syntax works for classes:
class Box[T]:def__init__(self, item: T) ->None:self.item = itemBox(7).item
7
Box[T] makes T a class-scoped parameter — every method body can reference it. Box(7) infers T = int; Box("hi") would infer T = str. Each instance carries its own T.
Type aliases get a dedicated type statement:
type Vector =list[float]type StrOrInt =str|int
type Vector = list[float] registers Vector as a type alias — mypy substitutes list[float] wherever you write Vector. Unlike Vector = list[float] (a plain assignment), the type statement is recognized as an alias declaration even in tricky scopes and is the form you should reach for in new code.
For covariance and contravariance, the explicit TypeVar(..., covariant=True) form is still needed (variance markers in PEP 695 are deferred). The variance section above stays as-is.
27.4.2Self (PEP 673, 3.11) for fluent builders
In a fluent-builder class, every method returns self so calls can chain. Annotating the return type as "QueryBuilder" (a string forward-reference) works for the base class — but on a subclass, mypy still types each chained call as QueryBuilder, losing the subclass type. Self is the fix: it means “instance of the class this method is defined on” and follows subclasses automatically.
where(self, clause: str) -> Self declares the return type as “the same class self belongs to.” On QueryBuilder that’s QueryBuilder; on a subclass MyQueryBuilder(QueryBuilder), it’s MyQueryBuilder — automatically.
QueryBuilder().where("a=1") returns the same instance. The next .where("b=2") chains on the same builder, accumulating clauses.
.parts at the end reads the final list — ["a=1", "b=2"].
The general pattern: use Self for any method that returns “another one of these” — fluent builders, alternative constructors that return cls(...) instances, copy methods. It’s the right return type whenever the method’s value tracks the receiver’s class.
27.4.3@override (PEP 698, 3.12)
A common refactoring bug: you rename a method on the parent class but forget to update the override on a subclass. Now the subclass has a new method with the old name, the parent’s method runs unchanged, and nothing flags the mistake. @override is the static guard: mark every intentional override, and mypy errors out the moment the parent loses the matching method.
Dog(Animal) overrides speak to return "woof". The @override decorator declares “I intend to be replacing a parent method.”
The decorator has no runtime effect — it just sets a marker mypy reads. If we later renamed Animal.speak to Animal.vocalize and forgot to update Dog, mypy would flag Dog.speak as a non-override and the bug would surface at type-check time.
The general guidance: annotate every intentional override with @override. The cost is one decorator per method; the benefit is rename-safety across the whole hierarchy.
27.4.4Never and LiteralString (one-line each)
Never annotates a function that doesn’t return — raise or infinite loop. LiteralString accepts only strings the type checker can prove are literal — useful for SQL/shell APIs that must refuse user-supplied strings.
Sometimes you need annotations at runtime — to drive a serializer, build a CLI from a function signature, or generate documentation. The raw __annotations__ dict is the underlying storage, but it’s brittle: under from __future__ import annotations the values are unresolved strings, and forward references are never expanded. typing.get_type_hints is the safer accessor — it resolves strings to real types and follows forward references.
from typing import get_type_hintsclass HintedClass: attr: int other: str="default"def method(self, x: float) ->bool:returnTrueget_type_hints(HintedClass)
{'attr': int, 'other': str}
get_type_hints(HintedClass.method)
{'x': float, 'return': bool}
Walking through the calls:
get_type_hints(HintedClass) returns a dict of class-level annotations: {"attr": int, "other": str}. The defaults aren’t part of the annotations — only the type next to each colon.
get_type_hints(HintedClass.method) returns the function’s annotations: {"x": float, "return": bool}. Note that self has no annotation by convention, so it’s absent from the result.
Compared with HintedClass.__annotations__, get_type_hints does the extra work of resolving any string-form annotations (via the function’s globals/locals) into real type objects.
The general rule: use get_type_hints whenever you need to introspect annotations at runtime. The raw __annotations__ dict is fine when you can guarantee the module doesn’t use forward references or from __future__ import annotations, but get_type_hints is the form that always works.
TipWhy this matters
Variance captures the direction of subtype relationships for generics. Covariance and contravariance are not just academic — they explain why list[int] is not a list[float] (mutation makes it unsafe), and why read-only containers can be covariant. If you produce T, you can be covariant. If you consume T, you can be contravariant.
27.6 Build: a fluent QueryBuilder with Self, @override, and a TypedDict result
A query-builder is a small DSL where chained method calls accumulate state and the last call materialises a result. It exercises the chapter’s modern features: Self for chain-friendly return types, @override for refactor-safety, and TypedDict for the shape of the materialised query.
Step 1: a base QueryBuilder returning Self from each fluent method. The Self annotation makes chained calls preserve the most-specific class — so subclasses’ .where(...) calls don’t widen back to QueryBuilder:
from typing import Self, TypedDictclass QueryBuilder:def__init__(self) ->None:self._table: str|None=Noneself._where: list[str] = []def from_(self, table: str) -> Self:self._table = tablereturnselfdef where(self, clause: str) -> Self:self._where.append(clause)returnselfdef build(self) ->str:ifself._table isNone:raiseValueError("from_() not called") sql =f"SELECT * FROM {self._table}"ifself._where: sql +=" WHERE "+" AND ".join(self._where)return sqlQueryBuilder().from_("users").where("active = 1").where("age > 18").build()
'SELECT * FROM users WHERE active = 1 AND age > 18'
-> Self reads “an instance of the class this method is defined on.” Because from_, where, and any subclass methods all return Self, the chained call stays in the same class — mypy knows QueryBuilder().from_(...).where(...) is still a QueryBuilder, not a widening to some base.
Step 2: a subclass with @override and a new fluent method.UserQueryBuilder validates clauses to forbid dangerous SQL, and adds a .by_email(...) shortcut. @override declares the intent to replace a parent method — mypy flags it if the parent’s where is renamed away:
from typing import overrideclass UserQueryBuilder(QueryBuilder):@overridedef where(self, clause: str) -> Self:if"DROP"in clause.upper() or"DELETE"in clause.upper():raiseValueError(f"dangerous clause rejected: {clause!r}")returnsuper().where(clause) # delegate to the basedef by_email(self, email: str) -> Self:returnself.where(f"email = {email!r}") # returns Self, still chainableq = (UserQueryBuilder() .from_("users") .by_email("alice@example.com") .where("active = 1"))q.build()
"SELECT * FROM users WHERE email = 'alice@example.com' AND active = 1"
UserQueryBuilder().from_(...) returns Self — and because Self follows the class, mypy narrows it to UserQueryBuilder, so .by_email(...) is callable on the result. With a plain -> "QueryBuilder" return type, the chain would lose the subclass and .by_email would fail type-check after the first chained call.
---------------------------------------------------------------------------ValueError Traceback (most recent call last)
CellIn[16], line 1----> 1 UserQueryBuilder().from_("users").where("DROP TABLE users")
CellIn[15], line 7, in UserQueryBuilder.where(self, clause) 4 @override
5def where(self, clause: str) -> Self:
6if"DROP"in clause.upper() or"DELETE"in clause.upper():
----> 7raise ValueError(f"dangerous clause rejected: {clause!r}")
8return super().where(clause) # delegate to the baseValueError: dangerous clause rejected: 'DROP TABLE users'
The @override validator rejects the dangerous clause at runtime. mypy separately verifies, at type-check time, that where actually overrides a parent method — the refactor-safety from earlier in the chapter.
Step 3: a TypedDict for the materialised query. Sometimes downstream code wants the parts of the query rather than the SQL string — TypedDict describes the shape without leaving the runtime as a plain dict:
{'table': 'users',
'clauses': ["email = 'bob@example.com'"],
'sql': "SELECT * FROM users WHERE email = 'bob@example.com'"}
CompiledQuery(TypedDict) is a static-only marker — at runtime, compile() returns a plain dict. But mypy checks that the literal has all three keys with matching types, and any downstream query["sql"] is statically typed as str. If we forgot to fill clauses, the type checker would catch it before runtime.
The build is the chapter at full strength: Self keeps fluent chains type-safe across subclasses (Step 1), @override declares intentional overrides and gets refactor-safety (Step 2), and TypedDict documents the shape of the materialised payload while keeping the runtime trivial (Step 3). Static guarantees at no runtime cost.
27.7 Exercises
Overload to_str. Write to_str that returns str for int, float, and bool. Use @overload to document the three signatures.
TypedDict vs dataclass. Take the BookDict example and reimplement as a @dataclass. What changes for the caller? For the type checker?
Why list is invariant. Sketch a two-sentence argument that list[Dog] cannot be a list[Animal] even though Dog is an Animal. Use the add_cat example.
Covariant container. Write ReadOnlyDict[K, V_co] that exposes only __getitem__, __contains__, and iteration. Why does the value type get to be covariant but the key type does not?
Runtime annotations. Use get_type_hints on a function with from __future__ import annotations at the top of the module. Explain why the result differs from __annotations__.
27.8 Summary
@overload, TypedDict, and variance are the three tools that make Python’s static type system useful at scale. They turn type hints from inline documentation into a real verifier — one that catches not just typos but design errors.
That closes Part III. Part IV turns to control flow and concurrency: operator overloading, iterators and generators, the with/match/else blocks, threads, processes, and asyncio. We start in Chapter 28 by giving our classes the same arithmetic that built-in numbers have.