Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate Python 3.12+ ~27 min· topic 4 of 9

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 str is a subtype of str.
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 type statement.
`__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.

main.pywhole filepython
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].

old.pywhole filepython
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 = item

04A 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. 1

    Make a Queue

    Using the Stack[T] example as a model, write class Queue[T] with put(item: T) and get() -> T (remove from the front with pop(0)). Hover over Queue[int]().get() in an editor with a checker to see int.

  2. 2

    Read the placeholders

    Print Stack.__type_params__ and biggest.__type_params__[0].__constraints__. Notice that these exist as real objects, which is how tools such as Pydantic can read generics.

Code & diagrams

Placeholders, three ways Python 3.12+ New tab

The same idea with TypeVar (old), def first[U] and class Box[V] (3.12), and a type alias. __type_params__ shows the placeholders.

Sign in to run this example in your browser.

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: T
A generic Stack, bounds and constraints Python 3.12+ New tab

Stack[int] is a hint only: the string still goes in at run time. Constraints and bounds are stored on the type variable.

Sign in to run this example in your browser.

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.Hashable
A recursive alias for JSON Python 3.12+python

The 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"}))   # 4

Break 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.

main.pywhole filepython
class Plain:
    pass


box = Plain[int]()
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 5, in <module>
box = Plain[int]()
~~~~~^^^^^
TypeError: type 'Plain' is not subscriptable

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 TypeVar made you declare by hand with covariant=True.

  • ▸

    A bounded type variable is better than a union when the output must match the input: def f[S: str](x: S) -> S returns a MyStr for a MyStr, while def f(x: str) -> str returns plain str.

  • ▸

    Also available: ParamSpec (3.10; keeps a wrapped function's parameters in a decorator, Topic 9.6) and TypeVarTuple (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 ordinary Stack. 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 plain Stack() 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 MyStr in, a MyStr out). With constraints, the result is one of the listed types: pass a subclass of str to a function constrained to (str, bytes) and the checker treats the result as plain str. A constrained variable also cannot be int | str at once (it is exactly one constraint per call). So prefer a bound or a Protocol (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 bare Box mean Box[int] to the checker, and Box.__type_params__[0].__default__ is int. Use it so existing code that wrote an unparameterised class keeps type-checking when you make the class generic. Use Self (Topic 10.5), not a type variable, for "the same class as this instance", and type[M] for "the class itself".

Remember this

  1. 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. 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 that Sequence means an ordered, indexable collection. Then def first(items: Sequence[T]) -> T says: a sequence of T goes in, one T comes out.

  3. 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: and class Box[T]:. The new form needs no import, no repeated name string, and the placeholder belongs only to that function or class.

  4. 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) and pop(self) -> T agree about T. When you write Stack[int](), you tell the checker that this stack holds ints, so pop() is known to give an int.

  5. 5

    Remember the key fact from Topic 10.1: **at run time the [int] does nothing.** Python does not store or enforce it, so Stack[int]() still happily accepts a string. The type variable exists for readers and the checker. (You can see the placeholders in __type_params__.)

  6. 6

    You can limit what a placeholder may be. A bound (T: str) says "str or any class built on str" (a subtype, Topic 8.5). Constraints (T: (int, float)) say "exactly int or float, one of them".

  7. 7

    Bounds let the function use the methods the bound promises, such as len(). Constraints suit a function like a if a > b else b that really handles a short list of types.

  8. 8

    A type alias gives a long hint a name. Since 3.12 the type statement 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 writes Pair = tuple[int, int] or TypeAlias.

Explain it without notes

01

What problem does a type variable solve?

02

What is the difference between a bound and constraints on a type variable?

03

Does Stack[int]() stop you pushing a string at run time? Why or why not?

04

How do the 3.12 generic syntax and TypeVar differ?

05

Explain variance: why is list[Dog] not a list[Animal], and what is the 3.12 syntax's advantage there?

Practice

01

Write last[T](items: Sequence[T]) -> T and print last([1, 2, 3]) and last("hey").

02

Write class Pair[A, B] holding two values, with a method swap() returning a Pair[B, A], and print the swapped pair's items.

03

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; TypeVar runs 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 with TypeVar.

  • 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 type statement.

  • Done when you can show that [int] is not enforced at run time.