Generics in Python: Type Variables and Parameterized Types#
A Pair class that holds two ints takes five lines. The moment you need a
pair of floats, a pair of strs, or a str-and-int pair, Python offers
a bad menu: copy the class once per element type, or annotate the fields as
Any and watch every checker guarantee evaporate. This chapter is about the
third option — generics, definitions written against types you have not
chosen yet.
We stage the failure first: an Any-typed Pair that both checkers wave
through while it silently computes "164" where 20 was intended. Then the
repair in Python 3.14’s modern syntax from
PEP 695 — class Pair[S, T]:,
def first[T](items: list[T]) -> T: — the vocabulary of type variables,
parameters, and arguments, and the formal view of list[int] as a type
constructor applied to a type. A clearly labeled section then covers the
legacy TypeVar/Generic spelling you will keep meeting in real code.
By the end you can write generic functions and classes that the checkers enforce, read both checkers’ diagnostics about them — including where the two disagree — and see exactly where a bare type parameter runs out of power, the problem the next chapter’s bounds and constraints exist to solve.
Prerequisites
This chapter uses the subtype relation \(S <: T\) (“an \(S\) can stand in for a
\(T\)”) from Subtypes and the substitution criterion from
Subsumption. The checker silence we stage below is
gradual typing at work, covered in Type Safety. Every
piece of checker output on this page comes from mypy 2.2.0 (run with
--strict) and pyright 1.1.411 (standard mode, except one output labeled
as strict) on Python 3.14.
Why Not Just Use Any?#
Start with the smallest useful aggregate: a class that carries two values, so a function can return both a minimum and a maximum at once.
1@dataclass
2class IntPair:
3 first: int
4 second: int
5
6
7def min_max(values: list[int]) -> IntPair:
8 return IntPair(min(values), max(values))
9
10
11print(min_max([3, 1, 4, 1, 5]))
IntPair(first=1, second=5)
IntPair does its one job well — and does exactly one job. The day you need
the extremes of a list of floats, you need a FloatPair; lexicographic
extremes of strings, a StrPair; a username with its numeric id, yet another
class. Every copy has the same body; only the annotations change. The logic
was never int-specific, but the class is.
The tempting exit is Any, the special type that is compatible with
everything:
1@dataclass
2class Pair:
3 first: Any
4 second: Any
5
6
7pair = Pair("16", "4") # strings slipped in where ints were intended
8total: int = pair.first + pair.second
9print(total, type(total).__name__)
164 str
One class now holds any two values — and the printed line is the bill for
it. The intent was to add 16 + 4 and get 20; two strings slipped in,
+ concatenated them, and a variable annotated int now holds the str
"164". No exception was raised, at any point. Collect the same code, with
two reveal_type probes, into pair_any.py and both checkers pass it
wholesale:
from dataclasses import dataclass
from typing import Any
@dataclass
class Pair:
first: Any
second: Any
pair = Pair("16", "4") # strings slipped in where ints were intended
total: int = pair.first + pair.second
reveal_type(pair.first)
reveal_type(total)
mypy --strict:
pair_any.py:13: note: Revealed type is "Any"
pair_any.py:14: note: Revealed type is "int"
Success: no issues found in 1 source file
pyright:
pair_any.py:13:13 - information: Type of "pair.first" is "Any"
pair_any.py:14:13 - information: Type of "total" is "int"
0 errors, 0 warnings, 2 informations
Name the phenomenon before fixing it. Any does not mean “any type, chosen
consistently”; it means unchecked. It is
gradual typing’s escape hatch: a value of static
type Any may flow into a slot of any other type — here, straight into
total: int — without complaint. Worse, the checker then believes the
annotation: total reveals as int even though a str sits in it at
runtime, so the false belief propagates to everything downstream.
What we actually want is to keep the single class but state a contract: the first field has some fixed type \(S\), the second some fixed type \(T\), the same \(S\) and \(T\) every time that particular pair is touched. That requires a variable that ranges over types the way an ordinary variable ranges over values — a type variable.
```{prf:remark} A type variable is not Any
:label: type-theory-04-generics-remark-typevar-vs-any
Both promise flexibility, but in opposite currencies. A type variable \(T\)
stands for a specific but unspecified type: within one use of the
definition, every occurrence of \(T\) refers to the same type, so a checker
can enforce consistency without knowing what \(T\) is. Any abandons the
question: it is compatible with everything in both directions, so every
occurrence is independent and nothing is enforced. Flexibility via Any
costs the guarantees; flexibility via \(T\) keeps them[1].
## One Class for Every Type: PEP 695 Type Parameters
Since Python 3.12, [PEP 695](https://peps.python.org/pep-0695/) lets a class
declare **type parameters** — the type variables it is defined over —
directly in its header, in square brackets. This is the modern spelling the
whole series targets:
```{code-cell} ipython3
@dataclass
class Pair[S, T]:
first: S
second: T
user = Pair("john_doe", 12345)
print(user)
The header class Pair[S, T]: introduces two type parameters, S and T,
scoped to the class body; the field annotations then use them. The
runtime behavior is unchanged — the payoff is entirely static. When you
construct Pair("john_doe", 12345), the checker infers the type
arguments from the constructor call — \(S \mapsto\) str,
\(T \mapsto\) int — and the value gets the parameterized type
Pair[str, int].
That inference is exactly what turns the earlier silent bug into a loud one.
In pair_generic.py, a function demands Pair[str, int] and the caller
builds the pair with its arguments swapped:
from dataclasses import dataclass
@dataclass
class Pair[S, T]:
first: S
second: T
def log_user(user: Pair[str, int]) -> None:
print(f"User: {user.first}, ID: {user.second}")
record = Pair(12345, "john_doe") # swapped: Pair[int, str]
reveal_type(record)
log_user(record)
mypy --strict:
pair_generic.py:15: note: Revealed type is "pair_generic.Pair[int, str]"
pair_generic.py:16: error: Argument 1 to "log_user" has incompatible type "Pair[int, str]"; expected "Pair[str, int]" [arg-type]
Found 1 error in 1 file (checked 1 source file)
pyright:
pair_generic.py:15:13 - information: Type of "record" is "Pair[int, str]"
pair_generic.py:16:10 - error: Argument of type "Pair[int, str]" cannot be assigned to parameter "user" of type "Pair[str, int]" in function "log_user"
"Pair[int, str]" is not assignable to "Pair[str, int]"
Type parameter "S@Pair" is invariant, but "int" is not the same as "str"
Type parameter "T@Pair" is invariant, but "str" is not the same as "int" (reportArgumentType)
1 error, 0 warnings, 1 information
The mistake that Any let crash — or worse, not crash — at runtime is now
rejected at static-analysis time. Read pyright’s explanation closely: it
says each type parameter is invariant, meaning Pair[int, str] and
Pair[str, int] are simply different types, neither substitutable for the
other. When, if ever, one application of a generic class may stand in for
another is variance, the subject of
Invariance, Covariance and Contravariance.
Three terms did the work in this section, and they are worth pinning down — they recur through the rest of the series:
Term |
What it names |
In the example |
|---|---|---|
Type variable |
A variable that ranges over types, not values |
|
Type parameter |
A type variable declared in a definition’s header |
the |
Type argument |
The concrete type supplied (or inferred) at a use site |
|
Under PEP 695 the first two coincide: declaring the parameter is creating
the variable. In the legacy spelling — covered in its own section below —
type variables are standalone TypeVar objects created before, and
independently of, the definitions that use them.
What Is a Generic Type, Formally?#
You have been consuming generics since your first annotation. list[int]
is the generic class list applied to the type argument int, and it
polices its elements exactly the way Pair[str, int] polices its fields
(list_append.py):
nums: list[int] = [1, 2, 3]
nums.append("four")
mypy --strict:
list_append.py:2: error: Argument 1 to "append" of "list" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
pyright:
list_append.py:2:13 - error: Argument of type "Literal['four']" cannot be assigned to parameter "object" of type "int" in function "append"
"Literal['four']" is not assignable to "int" (reportArgumentType)
1 error, 0 warnings, 0 informations
The pattern underneath — give a type, get a type — deserves a precise statement, because the variance rules of the variance chapter quantify over exactly this structure.
Definition 202 (Type Constructor and Parameterized Type)
Let \(\mathbf{Type}\) denote the universe of types. A type constructor of arity \(n \geq 1\) is a mapping
that takes \(n\) type arguments and yields a type. A generic type (in
Python, a generic class) is a definition that introduces one or more type
parameters; its name denotes the constructor \(C\). Subscripting the name —
Pair[str, int], list[int] — is application of the constructor, and
the resulting type \(C[T_1, \ldots, T_n]\) is called a parameterized
type.
Type constructors are to types what functions are to values: a function maps values to a value, a type constructor maps types to a type[2].
Concretely: list is a constructor of arity 1, so list[int] and
list[str] are two of its (infinitely many) applications; dict has
arity 2, giving dict[str, int]; our Pair has arity 2 as well. Note what
this makes of the bare name: Pair on its own is the constructor awaiting
arguments, not a finished type. Annotate with it unapplied and strict
mypy objects (Missing type arguments for generic type "Pair" [type-arg]) because the parameters silently default to Any — the very
hole we just climbed out of. Pyright’s standard mode accepts the bare form,
tracking the unknown parameters internally; this asymmetry previews the
divergence admonition below.
Remark 78 (Applying a constructor does not preserve subtyping)
Given \(S <: T\), nothing follows automatically about the relationship between \(C[S]\) and \(C[T]\):
Whether a constructor preserves the subtype relation, reverses it, or
discards it entirely is a property of the constructor itself, called its
variance. You have already seen evidence: pyright justified rejecting
the swapped pair by declaring Pair’s parameters invariant — different
arguments, unrelated types. The full classification is the subject of
Invariance, Covariance and Contravariance.
Generic Functions: One Binding per Call#
The same square-bracket syntax works on functions. A generic function declares its type parameters between the name and the argument list:
1def first[T](items: list[T]) -> T:
2 return items[0]
3
4
5print(first(["Alice", "Bob"]), first([3, 1, 4]))
Alice 3
Here T is scoped to the function alone, and it is bound afresh at every
call: pass a list[str] and \(T \mapsto\) str for that call; pass a
list[int] and \(T \mapsto\) int for the next. Probing the two calls above
with reveal_type (first_infer.py) shows both checkers agreeing:
mypy --strict:
first_infer.py:6: note: Revealed type is "str"
first_infer.py:8: note: Revealed type is "int"
pyright:
first_infer.py:6:13 - information: Type of "first(names)" is "str"
first_infer.py:8:13 - information: Type of "first(nums)" is "int"
The binding is a contract across the whole signature. This function promises
“a list of some type \(T\), plus one more \(T\), gives back a list of the same
\(T\)” — so feeding a str into a list[int] call makes the contract
unsatisfiable (append_mix.py):
def append_and_return[T](items: list[T], item: T) -> list[T]:
items.append(item)
return items
ints: list[int] = [1, 2, 3]
append_and_return(ints, "four")
mypy --strict:
append_mix.py:7: error: Cannot infer value of type parameter "T" of "append_and_return" [misc]
Found 1 error in 1 file (checked 1 source file)
pyright:
append_mix.py:7:25 - error: Argument of type "Literal['four']" cannot be assigned to parameter "item" of type "T@append_and_return" in function "append_and_return"
"Literal['four']" is not assignable to "int" (reportArgumentType)
1 error, 0 warnings, 0 informations
Same verdict, different diagnosis: mypy declines to solve for T at all,
while pyright solves \(T \mapsto\) int from the list and then rejects the
string. Keep that split in mind — the admonition at the end of the next
section shows a case where the two checkers’ solving strategies produce
different accepted types, not just different error messages.
Generic Classes: One Binding per Instance#
Where a generic function binds its type parameter per call, a generic class binds it per instance, and the binding is shared by every attribute and method that mentions it:
1class Stack[T]:
2 def __init__(self) -> None:
3 self._items: list[T] = []
4
5 def push(self, item: T) -> None:
6 self._items.append(item)
7
8 def pop(self) -> T:
9 return self._items.pop()
10
11
12stack = Stack[str]()
13stack.push("hello")
14stack.push("world")
15print(stack.pop())
world
Writing Stack[str]() applies the constructor first — producing the
parameterized type Stack[str] — and instantiates it second. For this
object’s whole lifetime, \(T \mapsto\) str: push only accepts str, pop
returns str, and the private list[T] is a list[str]. Unlike Pair,
the type argument here must be written explicitly, because __init__ takes
no argument from which the checker could infer it. Collect the class cell
and three lines of use into stack.py, and the contract bites exactly as
it did for list:
stack = Stack[str]()
stack.push("hello")
stack.push(123)
mypy --strict:
stack.py:14: error: Argument 1 to "push" of "Stack" has incompatible type "int"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
pyright:
stack.py:14:12 - error: Argument of type "Literal[123]" cannot be assigned to parameter "item" of type "str" in function "push"
"Literal[123]" is not assignable to "str" (reportArgumentType)
1 error, 0 warnings, 0 informations
The Stack page in the data-structures section builds this class out into a full, production-shaped container if you want a longer worked example.
Where mypy 2.2.0 and pyright 1.1.411 diverge on generics
The typing specification standardizes what generic types mean, not how a checker must infer type arguments — and on two inference questions from this chapter, the flagship checkers part ways.
Bare instantiation. Drop the type argument (stack_bare.py) and mypy
refuses to guess, demanding an annotation:
stack_bare.py:9: error: Need type annotation for "stack" [var-annotated]
Found 1 error in 1 file (checked 1 source file)
pyright (standard mode) reports 0 errors, 0 warnings, 0 informations,
silently tracking the instance as Stack[Unknown]; only its strict mode
surfaces the gap:
stack_bare_strict.py:12:1 - error: Type of "stack" is partially unknown
Type of "stack" is "Stack[Unknown]" (reportUnknownVariableType)
Mixed-argument solving. Call def same[T](x: T, y: T) -> T: with
same(1, "a") (same_solve.py) and both checkers accept the call — but
they disagree on what T became. mypy --strict:
same_solve.py:5: note: Revealed type is "object"
pyright:
same_solve.py:5:13 - information: Type of "same(1, "a")" is "int | str"
mypy solves by join — the nearest common supertype of int and str,
which is object — while pyright solves by union, keeping int | str.
Neither is wrong, but the downstream code they permit differs sharply: an
object supports almost nothing without narrowing, while a union keeps the
two-case structure. Code that must behave identically under both checkers
should not lean on the inferred T of a deliberately mixed call — and if
your intent is that mixing be rejected, plain type parameters cannot
express that; the
constrained type variables of the next chapter can.
The Legacy Spelling: TypeVar and Generic#
Everything above uses PEP 695 syntax, which landed in Python 3.12. Code
older than that — most production code, most tutorials, and
the next chapter of this series —
spells the same ideas with machinery from the typing module, and you need
to read both dialects fluently even if you only write the new one. This
section is exactly that legacy sidebar. The Stack above, in the old
spelling (stack_legacy.py):
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
stack = Stack[str]()
stack.push(123)
A TypeVar object is created as an ordinary module-level value, and the
class inherits from Generic[T] to declare which variables parameterize
it. To the checkers, the two spellings are interchangeable here: this file
draws word-for-word the same diagnostics as the PEP 695 Stack —
stack_legacy.py:15: error: Argument 1 to "push" of "Stack" has incompatible type "int"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
— and both checkers accept the legacy definitions themselves without comment. The translation table:
Modern (PEP 695, Python ≥ 3.12) |
Legacy (PEP 484) |
|---|---|
|
|
|
|
|
|
|
|
Two differences run deeper than spelling. First, a legacy TypeVar is a
reusable value — one T can parameterize many definitions, with scoping
rules subtle enough that PEP 484 devotes a
section
to them — whereas a PEP 695 parameter exists only inside its definition’s
header and body. Second, the TypeVar(...) call accepts extra arguments
that restrict what the variable ranges over: an upper bound
(TypeVar("T", bound=...)) or a set of constraints
(TypeVar("T", int, float)). Those restrictions are the subject of
Bound and Constraint, which teaches
them in this legacy spelling — after this section you can read every line
of it. The remaining machinery — PEP 695’s scoping rules, the type alias
statement, inferred-versus-declared variance, and when a TypeVar still
earns its keep in modern code — gets a dedicated chapter on the
series roadmap.
Where a Bare Type Parameter Runs Out of Power#
Generics look, so far, like a free lunch. Here is the bill. Try the obvious
generic addition function (add_unconstrained.py):
def add[T](x: T, y: T) -> T:
return x + y
mypy --strict:
add_unconstrained.py:2: error: Returning Any from function declared to return "T" [no-any-return]
add_unconstrained.py:2: error: Unsupported left operand type for + ("T") [operator]
Found 2 errors in 1 file (checked 1 source file)
pyright:
add_unconstrained.py:2:12 - error: Operator "+" not supported for types "T@add" and "T@add" (reportOperatorIssue)
1 error, 0 warnings, 0 informations
The rejection is correct, and it is the mirror image of this chapter’s
lesson. A bare T ranges over every type — dict[str, int], None, an
open file handle — and + is not defined for every type, so no checker can
defend the body. T guarantees consistency (“both arguments and the result
share one type”) but promises nothing about what that type supports. The
missing expressive power is the ability to shrink T’s range: “any type
with +”, or “one of int, float, str only”. That is precisely what
upper bounds and constraints provide, and
Bound and Constraint opens with this
very function — including why the fix is not the Union you might reach
for first.
Summary#
If this chapter compresses to a single sentence: a generic definition takes
types as parameters the way a function takes values as arguments — Pair
is a type constructor, Pair[str, int] is its application, and the checker
holds every use to whatever binding it inferred. Along the way:
Anybuys flexibility by turning the checker off: bothmypy --strictandpyrightblessed code that computed"164"where20was intended. A type variable buys the same flexibility while keeping every occurrence consistent.PEP 695 declares type parameters in the header —
class Pair[S, T]:,def first[T](items: list[T]) -> T:— and the checker infers type arguments at each use, rejectingPair[int, str]wherePair[str, int]is demanded.Formally, a generic class names a type constructor \(C : \mathbf{Type}^n \to \mathbf{Type}\); subscripting applies it, and the result is a parameterized type. Application does not automatically preserve \(S <: T\) — that is variance.
Functions bind their type parameters per call; classes per instance. Where inference is underdetermined, the checkers diverge: join versus union, refusal versus
Unknown.The legacy
TypeVar/Genericspelling is checker-equivalent for everything shown here, and it is the dialect of most existing code.
Two threads continue directly from here. A bare T cannot promise any
behavior, so
Bound and Constraint in Generics
restricts a type variable’s range with upper bounds and constraint sets —
picking up the exact add function that failed above. And parameterizing a
constructor raises the substitution question this chapter deliberately left
open — when does \(S <: T\) lift to \(C[S] <: C[T]\)? — which
Invariance, Covariance and Contravariance answers in full.
References and Further Readings#
The motivating arc of this chapter — the IntPair duplication problem, the
Any-typed Pair, and its generic repair — is adapted from
Unit 20: Generics
of the CS2030S course at the National University of Singapore, translated
from Java to Python and re-verified against Python’s checkers. The debt is
genuine: their staging of duplication-versus-erasure is what this chapter
modernizes onto PEP 695. The add example follows a
Stack Overflow answer on Generic and TypeVar.
References