Generics let you write a function or class once and have the type checker carry the element type through every call. The payoff is real: a single Repository class can serve products, orders, and customers without losing type information, and a single first helper can return a Product to one caller and a Customer to another with no casts. This lesson covers TypeVar, generic functions, generic classes, bounded and constrained type variables, variance basics, and the new PEP 695 syntax that ships with Python 3.12.
Suppose you're building an online store. You have a list of products and you want a small helper that returns the first item, or None if the list is empty. The first attempt looks fine:
The code runs, but the type checker has no idea what top is. As far as it knows, the return type is object, so accessing top["name"] is suspect. You can swap object for Any and silence the checker, but now you've turned off the safety net for every caller:
This is the trade everyone makes when they don't reach for generics: either lose type information at the boundary, or paper over it with Any. Both options force callers to remember what type they put in and cast on the way out. Across a real codebase with list[Product], list[Order], list[Customer], and more, the cost adds up. Every caller has to know what came in to know what comes out, and the type checker is no help.
The fix is to tell the type system "whatever element type goes in, the same element type comes out." That's exactly what a type variable expresses.
Now the checker knows: if you pass a list[Product], you get back Product | None. Pass a list[Customer], you get back Customer | None. The function body is unchanged. The signature carries the knowledge through.
The diagram shows the key idea. first doesn't have one fixed input or output type. The type checker picks a concrete type for T at each call site, based on what you passed in, and propagates it to the return type.
A TypeVar is a placeholder for "some specific type, decided at the call site." You import it from typing and bind it to a name:
The string "T" is the type variable's name and must match the Python variable name you assigned it to. The convention is single uppercase letters: T for "type", K for "key", V for "value", E for "element". You'll see longer names like ModelT or RowT in larger codebases, especially when several type variables coexist in one signature and single letters get confusing.
A TypeVar is not a regular type. You don't write def foo(x: T) -> T: and expect T to be a class. The type checker reads the signature and notices that the same T appears in two places, which means "these two positions share whatever type ends up here." At runtime, T is just an object, useful for introspection but not used for actual type enforcement.
A few rules worth knowing up front:
| Rule | Why it matters |
|---|---|
The name in TypeVar("T") must equal the Python variable name | The type checker matches on the string; mismatches give confusing errors |
Each TypeVar is independent | T and U can resolve to different types in the same signature |
TypeVar exists for the type checker, not runtime | Python doesn't enforce T at runtime; you'd use isinstance for that |
You usually declare type variables once at the top of a module and reuse them across functions and classes in that module:
That's the foundation. The interesting part is what happens when you put a TypeVar into a function or class signature.
A function is generic if its signature uses one or more TypeVars. The classic shapes are "container in, element out," "element in, container out," and "container in, container out with the same element type." Each pattern is one signature change away from the non-generic version.
Here's the e-commerce first helper, fully typed:
The type checker sees the call first(products) and reasons: products is a list[dict[str, ...]], so T binds to the dict type for this call, and the return type is therefore Optional[dict[str, ...]]. The next call first(customers) solves for T again, completely independently. Each call site gets its own answer.
You can use the same TypeVar in multiple positions to express "these must be the same type":
Here, both arguments must be the same type. If you tried pair("Wireless Mouse", 29.99), a type checker would either pick the common supertype (often object) or flag the call as a likely mistake, depending on its inference rules. Either way, the signature says "left and right share a type," which is much more precise than pair(left: Any, right: Any).
You can use more than one TypeVar when arguments are independent:
K and V resolve independently. The type checker knows the returned tuple has the value type first and the key type second. A small but useful detail: a generic function doesn't always have to return the same type it received. The element-in, container-out shape is common too:
The checker infers list[float] and list[str] for the two calls. Same body, two different inferred return types.
Functions are the easy case. The bigger payoff is generic classes, which let you build reusable containers and services that carry the element type through every method. A classic example is a repository, the layer that fetches and stores domain objects.
Without generics, you'd write one repository per type and duplicate most of the code. With generics, you write it once:
A class becomes generic by inheriting from Generic[T]. The T in the inheritance list is what tells the type system "this class is parameterized by T, and every T inside the class body refers to the same parameter."
Here's how callers use it for two different domain types:
Two things happen here. First, Repository[Product] and Repository[Customer] are two different parameterized types from the checker's view. Second, every method picks up the binding: products.get(mouse_id) returns Optional[Product], while customers.get(alice_id) returns Optional[Customer]. You don't have to repeat the type anywhere inside the method bodies.
The diagram makes the relationship concrete. There's one generic class definition at the top, and instantiating it with different concrete types produces what the checker treats as distinct types, each with its methods specialized to that element type.
If you skip the annotation when constructing the repository, the type checker still tries to infer the parameter from usage:
Repository[Product]() is the explicit form. It calls the class normally, and the [Product] part tells the checker which T to bind. Most checkers also accept inference from the annotation on the variable, as in the earlier example.
You can have more than one type parameter:
Cache[int, Product] binds K to int and V to Product. Methods that mention K or V pick up those bindings consistently. Trying to call product_cache.put("one", ...) would now be flagged, because the first argument has to be an int.
Cost: Generics live at the type-check layer. At runtime, Repository[Product] and Repository[Customer] are the same Python class; the parameter is erased. This means isinstance(x, Repository[Product]) raises a TypeError, and you can't dispatch on the parameter at runtime. If you need runtime-aware behavior, store the type explicitly: def __init__(self, model_type: type[T]).
A plain TypeVar makes no assumptions about the type. That's flexible, but it also means you can't call any methods on it inside the function body, because the checker has no idea what methods are available. If you want to constrain T to "any type that has a certain capability," you use a bound.
A bound says "T can be any subtype of this base class." Inside the function or class, you can then call any method that the base class defines, because every possible T is guaranteed to have it.
E-commerce example: imagine a base Item class with a total_value method, used by both Product (price times stock) and OrderLine (price times quantity). A summarizer function should accept any subtype of Item and return the same subtype:
Two things are going on. First, the bound bound=Item tells the checker that any ItemT is at least an Item, so item.total_value() inside the function is valid. Without the bound, the checker would complain that total_value might not exist on ItemT. Second, the return type is still ItemT | None, not Item | None. If you pass list[Product], you get Product | None back, not the watered-down Item | None. The bound restricts what T can be, but it doesn't widen the inferred return type to the bound itself.
This is the typical shape: you need to call methods on T, so you constrain T to something that has those methods, but you still want the precise input type to survive through to the output.
Another common bound is Hashable, when T has to work as a dict key or a set element:
The bound prevents callers from passing list[list[str]] or anything else unhashable, which would crash at runtime when you call seen.add(...). The type checker catches that mistake before the program runs.
Note: Bounds are upper bounds in the subtype lattice. bound=Item accepts Item itself and any subclass. It does not accept unrelated types that happen to have a total_value method. If you want "any type that has this method, regardless of inheritance," you want a Protocol and you'd bound the TypeVar on the protocol.
A useful pattern is bounding on a Protocol from the standard library, like SupportsAbs or SupportsRichComparison. A more practical e-commerce version: bound on SupportsFloat to accept anything you can convert to a float, including custom price-wrapper objects:
The bound says "any type that supports the __float__ protocol." Inside the function, calling float(v) is safe for any NumericT.
A bound says "any subtype of this." A constrained type variable is stricter: it lists a fixed set of allowed types, and at each call site T must be exactly one of them, not a subtype. The syntax is TypeVar("T", X, Y, Z) with multiple positional arguments instead of bound=.
The classic use case is overloads where the input type determines the output type, and the two are not related by inheritance:
Number is constrained to exactly int or float. At each call site, the checker picks one of those two and uses it for both the parameter and the return. Pass a float, get a float back. Pass an int, get an int back. The checker would reject round_price("29.99") because str isn't in the constraint list, even though str would happily satisfy a bound=object.
The difference from bounds matters in one specific way. With bound=Number, a subclass of int would still be accepted and the return type would be that subclass. With the constrained version, the checker collapses any input to the nearest listed type, so passing True (which is technically bool, a subclass of int) resolves Number to int, not bool.
Here's a table that captures the difference:
| Feature | Bounded TypeVar("T", bound=X) | Constrained TypeVar("T", X, Y) |
|---|---|---|
What T can be | Any subtype of X | Exactly one of the listed types |
| Subclasses of listed types | Allowed, return type stays the subclass | Allowed at runtime; checker collapses to nearest listed type |
| Inside the function, can call | Methods defined on X | Methods common to all listed types (intersection) |
| Typical use | "Anything with this capability" | "One of these specific types, picked per call" |
Constrained type variables are less common than bounded ones. Reach for them when the allowed types don't share a meaningful base class but you genuinely have a small, closed set of options.
Here's a subtlety that trips people up. Product is a subclass of Item, so you'd expect list[Product] to be a subtype of list[Item]. It isn't. Type checkers will reject code that passes one where the other is expected. The reason is variance, and the short version is: it depends on what you can do with the container.
Consider this code, which looks innocent:
If the checker had allowed the commented-out call, add_generic_item could legally append a plain Item to a list the caller still thinks is list[Product]. The caller's next operation that assumes "every element is a Product" would break. So list[Product] is not a subtype of list[Item], even though Product is a subtype of Item. This property is called invariance: list[X] and list[Y] are unrelated as types unless X and Y are exactly the same.
Some container types are different. Sequence[X] from collections.abc is covariant: if Product is a subtype of Item, then Sequence[Product] is a subtype of Sequence[Item]. This works because Sequence is read-only from the type system's view: it doesn't have an append method, so the dangerous "smuggle an Item into a list[Product]" scenario can't happen.
The function only reads from items, so the type system allows the wider parameter and the narrower argument. If you want functions that accept "any sequence of items including subclasses," type the parameter as Sequence[Item], not list[Item].
There's a flip side: contravariance, which shows up in inputs to callbacks. A function that takes an Item callback would accept a callback that takes any superclass of Item. This is rarer in everyday code; the common rule of thumb is:
Iterable[T] when you only read. Covariant; accepts subclass containers.You don't have to remember the formal rules to write good Python. The practical version is: prefer reading-only abstract types in your parameter annotations when you don't mutate the container, and you'll avoid most variance pain.
Python 3.12 introduced a cleaner way to declare generic functions and classes. Instead of importing TypeVar and Generic and declaring everything in advance, you put the type parameter directly in brackets on the function or class definition. The old syntax still works, and most existing codebases use it, so you'll see both for years to come.
Side-by-side comparison for a generic function:
The [T] after first introduces the type parameter for this function. No top-level TypeVar declaration, no import. The T is scoped to the function signature and body, which is cleaner than having module-level T floating around.
The same simplification works for classes:
Bounded and constrained parameters work too, with familiar syntax:
The colon-and-type form expresses a bound (ItemT: Item), and the colon-and-tuple form expresses constraints (Number: (int, float)). The result is more concise and keeps everything local to the definition.
A quick reference of when each syntax fits:
| Situation | Recommendation |
|---|---|
| New code on Python 3.12+ | PEP 695 syntax, fewer imports and less ceremony |
| Library targeting Python 3.10 or 3.11 | Old TypeVar / Generic syntax, no choice |
| Existing codebase already on the old style | Stay consistent; mixing styles in one file is jarring |
| Teaching material and tutorials | Old syntax is still what most search results show, so it's worth recognizing |
Tools like mypy and pyright have both syntaxes supported in recent versions, and Python itself will keep TypeVar and Generic working indefinitely. The new syntax is a nicer surface; the underlying ideas are the same.
Generics shine when you combine them. Here's a short but realistic e-commerce sketch: a generic Repository[T] with a generic Service[T] that wraps it. The service adds logging-style print output, but the type checker carries the element type through both layers.
Service[Product] requires Repository[Product] in its constructor. The type checker enforces that, and find returns Optional[Product] because T is bound to Product for this instance. Swap Product for Customer everywhere and the same code structure handles customers, with no runtime dispatch or isinstance checks needed in the service layer.
The benefit isn't just less code. It's that the type checker becomes your second pair of eyes. If someone passes a Repository[Customer] to a Service[Product], the mistake gets flagged at the construction site, not three layers deeper when a method finally tries to use the wrong type.
10 quizzes