Python Generic Syntax: TypeVar, Generic, and PEP 695
python generic syntax: Learn how to write generic classes and functions in Python using TypeVar, Generic, and the new PEP 695 syntax for better static type checking.
python generic syntax requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
Python's generic syntax lets you write functions and classes that work with any type while preserving type information for static type checkers. The core idea is to declare a type variable, then use it in signatures to describe relationships between inputs and outputs. This article explains the syntax, how to apply it, and where it breaks down at runtime.
Declaring a Generic Class
The traditional way to create a generic class is to inherit from Generic[T], where T is a TypeVar. This tells type checkers that the class uses a type parameter that can be substituted with a concrete type when the class is instantiated.
from typing import TypeVar, Generic T = TypeVar('T') class Box(Generic[T]): def __init__(self, item: T) -> None: self.item = item def get(self) -> T: return self.item
When you use Box[int], the type checker knows that get() returns an int. Without the generic annotation, get() would be typed as Any, and you would lose the ability to catch type mismatches at static analysis time.
The Generic[T] base class is a marker. It does not change runtime behavior; it only provides metadata for static type checkers. At runtime, Box is just a normal class, and Box[int] is the same object as Box[str] because type arguments are erased.
Writing Generic Functions
Generic functions use the same TypeVar mechanism. The type variable can appear in the parameter types and the return type, allowing the function to express a relationship between them.
from typing import Sequence, TypeVar T = TypeVar('T') def first(seq: Sequence[T]) -> T: return seq[0]
Here, first accepts any sequence (list, tuple, string) and returns an element of the same type. If you call first([1, 2, 3]), the return type is inferred as int. If you call first(("a", "b")), it is inferred as str.
Type variables can also be used to enforce that two arguments share the same type:
def pair(a: T, b: T) -> tuple[T, T]: return (a, b)
This ensures that both arguments have the same type, which is useful for functions that combine values.
Constraining Type Variables with Bounds
A bare TypeVar accepts any type. When you need to restrict the possible types, use the bound parameter. The bound specifies an upper limit; the type variable can only be substituted with that type or a subtype of it.
from typing import TypeVar class Number: pass class IntNumber(Number): pass N = TypeVar('N', bound=Number) def add_numbers(a: N, b: N) -> N: # Implementation that works with Number and its subclasses ...
This is useful when the generic implementation relies on methods defined in the bound class. Without the bound, the type checker would reject calls to those methods because it cannot guarantee they exist.
Bounds also work with built-in types. For example, TypeVar('T', bound=float) allows int and float but not str.
Understanding Variance and Invariance
Variance describes how generic types behave with respect to subtyping. In Python's typing system, a TypeVar can be marked as covariant, contravariant, or left invariant (the default).
- Invariant (default):
Box[int]is not a subtype ofBox[float], even thoughintis a subtype offloat. This is the safest default because it prevents accidental type mismatches when the generic type is used for both reading and writing. - Covariant (
covariant=True):Box[int]is a subtype ofBox[float]. This is suitable for types that produce values, such as an immutable container. - Contravariant (
contravariant=True):Box[float]is a subtype ofBox[int]. This is suitable for types that consume values, such as a callback.
Consider a simple producer:
T_co = TypeVar('T_co', covariant=True) class Producer(Generic[T_co]): def produce(self) -> T_co: ...
Marking T_co as covariant lets you assign Producer[int] to a variable of type Producer[float] because int is a subtype of float. This works because the producer only returns T, never accepts it as an input.
If you marked the same TypeVar as contravariant, the assignment would be reversed. Most user-defined generics should stay invariant unless you have a clear reason to change variance. Misusing variance can lead to subtle type errors that are hard to debug.
PEP 695: The New Generic Syntax
Python 3.12 introduced a more concise syntax for generics. Instead of inheriting from Generic[T], you can declare type parameters directly in the class or function definition.
class Box[T]: def __init__(self, item: T) -> None: self.item = item def get(self) -> T: return self.item def first[T](seq: Sequence[T]) -> T: return seq[0]
This syntax is less verbose and removes the need to define a TypeVar separately. The type parameter T is scoped to the class or function, and it can be used in the same ways as before. PEP 695 also introduces a type statement for defining type aliases, but the generic syntax is the most visible change.
Note that this syntax requires Python 3.12 or later. If you are targeting older versions, you must use the typing.Generic approach. The two styles are not interchangeable at runtime, but they are semantically equivalent for static type checkers.
Runtime Behavior: Type Hints Are Not Enforced
Generic type hints are erased at runtime. When you define class Box[T], Python does not create a distinct class for each type argument. Box[int] and Box[str] refer to the same class object. The type parameters are only available through __class_getitem__ for introspection, but they do not affect the actual behavior of the class.
This means that generic syntax provides no runtime type checking. If you call box.get() and assign the result to a variable expecting a string, no error occurs at runtime; the type checker is the only tool that can catch the mismatch. This is a deliberate design choice: Python's typing system is for static analysis, not runtime enforcement.
If you need runtime validation, you must implement it separately, for example with isinstance checks or by using a library like pydantic. Generics do not replace runtime validation; they complement it by giving you early feedback during development.
Choosing Between Generics and Union Types
Generics and union types (Union[int, str] or int | str) serve different purposes. A union type says "this value can be one of a fixed set of types." A generic says "this value can be any type, but the relationship between inputs and outputs is consistent."
Use a union when the set of possible types is known and finite:
def parse(value: int | str) -> int: ...
Use a generic when you want to preserve the specific type across a boundary:
def identity(value: T) -> T: return value
If you tried to write identity with a union, you would lose the exact type of the input. The return type would be int | str, forcing the caller to narrow it again. Generics avoid that narrowing step by carrying the type through.
A practical rule: if the function or class must work with an open set of types and the type relationship matters, use a generic. If the set of types is closed and you need to handle each case differently, use a union. Mixing both is also possible, such as def process(items: list[T]) -> T, where T itself could be a union if needed.
Generics add complexity to the signature. For a one-off script where type safety is less critical, a simpler annotation like Any or a union may be sufficient. But for library code or long-lived projects, the extra precision often pays off by catching bugs earlier and making the API self-documenting.