Topic 10.4
Generics and Type Variables
In one line
A generic function or class works for many types while keeping track of which: first([1, 2]) gives an int, first(['a']) gives a str. You declare the placeholder with TypeVar or, since Python 3.12, with the clean def f[T](x: T) and class Box[T] syntax.
Think of it like this
A labelled lunchbox that can hold one kind of food at a time. The box itself does not care if it is fruit or bread, but once you put apples in, only apples come out. A generic is the box: written once, used for many contents, with the content type remembered.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Signature
- The first line of a function: its name, the parameters with their labels, and the return label.
- Subtype
- A type that is the given type or inherits from it (Topic 8.5). A class built on
stris a subtype ofstr. - Generic
- Code written once that works for many types and keeps track of which.
- Type variable
- A placeholder for a type, written
T, that stands for the same type throughout one signature. - `TypeVar`
- The older way to declare a type variable:
T = TypeVar("T"). - Bound
- A limit on a type variable: any subtype of a given type, like
T: str. - Constraints
- A short list of exact types a variable may be, like
T: (int, float). - Type alias
- A short name for a long type, made with the
typestatement. - `__type_params__`
- An attribute holding the placeholders a generic function, class or alias declares.
Step by step
01The problem generics solve
A function that returns the first item should return "the same kind of thing the list holds". If you hint the return as object, a checker forgets that a list of ints gives an int. The placeholder T repeats the same word in two places and the checker connects them.
02The 3.12 syntax
Declare the placeholder in square brackets after the function or class name. It is visible only inside that definition. first([10, 20]) is an int, first("abc") is a str, with no cast and no repetition.
from collections.abc import Sequence
def first[T](items: Sequence[T]) -> T:
return items[0]
class Box[T]:
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
type Pair[A] = tuple[A, A]03The older TypeVar form
Before 3.12 (and still in code that supports older Pythons) you declare the variable at module level. The name string must match the variable name. For a generic class you also inherit from Generic[T].
from typing import Generic, TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
class Box(Generic[T]):
def __init__(self, item: T) -> None:
self.item = item04A generic container: Stack
A stack that remembers its item type is the classic example. Stack[int] tells a reader and a checker that this one holds ints. Run the second example to see that Python itself does not care: pushing "oops" into a Stack[int] works at run time.
05Bounds and constraints
def longest[S: str](a: S, b: S) -> S accepts any string subtype and returns that same subtype; inside, you may use len because every S is a str. def biggest[N: (int, float)](a: N, b: N) -> N accepts two ints or two floats. With constraints that are unrelated (for example (str, bytes)) a checker complains when you mix them; int and float are the special case, because a checker accepts an int wherever a float is expected, so a mixed call is solved as float. Use a bound when a family of types shares behaviour; constraints when a few specific types do.
06Type aliases
type Pair[A] = tuple[A, A] is lazy: Python stores the right-hand side as Pair.__value__ and evaluates it only when asked. That is what lets a recursive alias such as type Json = dict[str, Json] | list[Json] | str | int | float | bool | None work. It describes any JSON document in one line.
07Generics in AI code
Typed results are everywhere in LLM code. A helper that calls a model and parses the answer into a class of your choice is generic: def parse[M: BaseModel](text: str, model: type[M]) -> M (BaseModel is the class every Pydantic model builds on, Topic 15.4; type[M] means "the class M itself, not an object of it"). The caller passes Weather and the checker knows the result is a Weather. Pydantic and the major SDKs use exactly this pattern.
Try it yourself
- 1
Make a Queue
Using the
Stack[T]example as a model, writeclass Queue[T]withput(item: T)andget() -> T(remove from the front withpop(0)). Hover overQueue[int]().get()in an editor with a checker to seeint. - 2
Read the placeholders
Print
Stack.__type_params__andbiggest.__type_params__[0].__constraints__. Notice that these exist as real objects, which is how tools such as Pydantic can read generics.
Code & diagrams
The same idea with TypeVar (old), def first[U] and class Box[V] (3.12), and a type alias. __type_params__ shows the placeholders.
Expected output
10 a 1.5
5 hi
type params of first: (U,)
type params of Box: (V,)
alias: Pair tuple[A, A]
T name: TStack[int] is a hint only: the string still goes in at run time. Constraints and bounds are stored on the type variable.
Expected output
2 1
class without [int]: <class '__main__.Stack'> | with: __main__.Stack[int]
items now: [1, 'oops']
9 2.5 coffee
constraints: (<class 'int'>, <class 'float'>)
bound: <class 'str'>
old style bound: typing.HashableThe type statement is lazy, so an alias can refer to itself. This runs on 3.12 and later. The match statement is Topic 2.8, and a recursive function (one that calls itself) is Topic 5.8.
type Json = dict[str, Json] | list[Json] | str | int | float | bool | None
def count_leaves(value: Json) -> int:
match value:
case dict():
return sum(count_leaves(v) for v in value.values())
case list():
return sum(count_leaves(v) for v in value)
case _:
return 1
print(count_leaves({"a": [1, 2, {"b": None}], "c": "x"})) # 4Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Subscript a class that is not generic
Write [int] after a class that was not declared with a type parameter.
class Plain:
pass
box = Plain[int]()Myth vs fact
Myth
Stack[int] makes Python reject non-int items.
Fact
It does not. The [int] is only a hint for readers and checkers; the example above pushes a string into a Stack[int] without any error.
Myth
Generics are an advanced topic only library authors need.
Fact
You use them every time you write list[int]. Writing your own is needed whenever a helper must give back "the same type it was given".
Myth
T is a special name.
Fact
Any name works. T, K, V, U are conventions: T for type, K and V for key and value.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Prefer the 3.12 syntax when your minimum version is 3.12. It scopes the variable to the definition and lets a checker infer the variance (read-only containers are covariant, mutable ones invariant) automatically, which the old
TypeVarmade you declare by hand withcovariant=True. - ▸
A bounded type variable is better than a union when the output must match the input:
def f[S: str](x: S) -> Sreturns aMyStrfor aMyStr, whiledef f(x: str) -> strreturns plainstr. - ▸
Also available:
ParamSpec(3.10; keeps a wrapped function's parameters in a decorator, Topic 9.6) andTypeVarTuple(3.11; variable-length shapes such as array dimensions). Both are 3.12-syntax friendly:def f[**P, R](...). - ▸
At run time
Stack[int]creates a small alias object and calling it builds an ordinaryStack. After__init__has finished, Python also sets__orig_class__on the instance to the alias (__main__.Stack[int]), so the parameter can be read afterwards but **not inside__init__**, and an instance made as plainStack()has no__orig_class__at all. Pydantic does something different and stricter:Model[int]builds a separate real subclass with validated fields. - ▸
Bound versus constraints, the part that bites. With a bound, the result keeps the exact subtype you passed in (a
MyStrin, aMyStrout). With constraints, the result is one of the listed types: pass a subclass ofstrto a function constrained to(str, bytes)and the checker treats the result as plainstr. A constrained variable also cannot beint | strat once (it is exactly one constraint per call). So prefer a bound or aProtocol(Topic 10.6) unless the function genuinely needs one specific result type per listed type. - ▸
Python 3.13 added defaults for type parameters (PEP 696):
class Box[T = int]makes a bareBoxmeanBox[int]to the checker, andBox.__type_params__[0].__default__isint. Use it so existing code that wrote an unparameterised class keeps type-checking when you make the class generic. UseSelf(Topic 10.5), not a type variable, for "the same class as this instance", andtype[M]for "the class itself".
Remember this
- 1
Without generics you have two bad choices. Hint a function
def first(items: list) -> object, and callers lose the information that a list of ints gives an int. Or write one version per type. - 2
The way out is a type variable: a placeholder name, usually
T, that stands for "whatever type this is, the same everywhere in this signature". (A signature is the first line of a function: its name, parameters and return label.) Recall from Topic 10.2 thatSequencemeans an ordered, indexable collection. Thendef first(items: Sequence[T]) -> Tsays: a sequence of T goes in, one T comes out. - 3
The old way (still everywhere) declares the placeholder yourself:
T = TypeVar("T"). Python 3.12 added a cleaner syntax where the placeholder is declared inside square brackets right at the function or class:def first[T](items: Sequence[T]) -> T:andclass Box[T]:. The new form needs no import, no repeated name string, and the placeholder belongs only to that function or class. - 4
A generic class hands its placeholder to every method (a method is a function inside a class, Topic 8.1). A stack is a pile where you add and remove at the top, like plates. In
class Stack[T],push(self, item: T)andpop(self) -> Tagree about T. When you writeStack[int](), you tell the checker that this stack holds ints, sopop()is known to give anint. - 5
Remember the key fact from Topic 10.1: **at run time the
[int]does nothing.** Python does not store or enforce it, soStack[int]()still happily accepts a string. The type variable exists for readers and the checker. (You can see the placeholders in__type_params__.) - 6
You can limit what a placeholder may be. A bound (
T: str) says "stror any class built onstr" (a subtype, Topic 8.5). Constraints (T: (int, float)) say "exactly int or float, one of them". - 7
Bounds let the function use the methods the bound promises, such as
len(). Constraints suit a function likea if a > b else bthat really handles a short list of types. - 8
A type alias gives a long hint a name. Since 3.12 the
typestatement does it:type Pair[A] = tuple[A, A]creates a (generic) alias that Python works out only when needed. That means it can even refer to itself, which is how you describe nested JSON (JSON is the text format for data from Topic 7.7). Older code writesPair = tuple[int, int]orTypeAlias.
Explain it without notes
What problem does a type variable solve?
What is the difference between a bound and constraints on a type variable?
Does Stack[int]() stop you pushing a string at run time? Why or why not?
How do the 3.12 generic syntax and TypeVar differ?
Explain variance: why is list[Dog] not a list[Animal], and what is the 3.12 syntax's advantage there?
Practice
Write last[T](items: Sequence[T]) -> T and print last([1, 2, 3]) and last("hey").
Write class Pair[A, B] holding two values, with a method swap() returning a Pair[B, A], and print the swapped pair's items.
Write type Matrix = list[list[float]] and a function that returns the sum of every number in it.
Trade-offs
- ↔
Reuse versus readability. A generic with three type variables is powerful and hard to read. Stop at one or two.
- ↔
New syntax versus compatibility. The 3.12 syntax is cleaner;
TypeVarruns everywhere. Your minimum supported Python decides. - ↔
Hints only. Generics are checked by tools, not by Python. For data you do not control, validate at run time.
Done when you can
Done when you can write a generic function with
def f[T]and withTypeVar.Done when you can write a generic class such as a
Stack[T].Done when you can say what a bound and constraints do.
Done when you can make a type alias with the
typestatement.Done when you can show that
[int]is not enforced at run time.