Python Generic Class: Typing Reusable Components
Learn how to create Python generic classes with TypeVar and Generic to build type-safe, reusable components. Covers bounds, variance, runtime behavior, and common mistakes.
When you write a class that should work with multiple types while preserving type information, a Python generic class lets you declare that relationship explicitly. The typing module provides TypeVar and Generic to define classes that accept type parameters, so static type checkers can verify usage without forcing you to duplicate code.
Declaring a Generic Class with TypeVar
The core of a generic class is a TypeVar, which acts as a placeholder for a type that will be supplied later. To create a generic class with this classic approach, you define a TypeVar, then inherit from Generic[T].
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
Here, T is a type variable that is replaced by a concrete type when the class is subscripted, such as Box[int]. Type checkers treat Box[int] and Box[str] as separate instantiations and will enforce that get() returns the same type that was passed to the constructor. This is the primary benefit: you get static, development-time type checking without writing separate implementations for each type.
Using Multiple Type Parameters
A generic class can accept more than one type parameter. This is useful for containers that pair two related types, such as a dictionary-like wrapper or a repository that maps keys to values.
from typing import TypeVar, Generic K = TypeVar("K") V = TypeVar("V") class KeyValueStore(Generic[K, V]): def __init__(self) -> None: self._data: dict[K, V] = {} def set(self, key: K, value: V) -> None: self._data[key] = value def get(self, key: K) -> V | None: return self._data.get(key)
When you use KeyValueStore[str, int], the type checker knows that set expects a string key and an integer value, and get returns int | None. The V | None annotation uses Python 3.10's union syntax; on older versions, use Optional[V] from typing. Multiple parameters allow you to model relationships precisely, which reduces the chance of accidentally mixing incompatible types.
Constraining Type Parameters with Upper Bounds
Sometimes you want a generic class to accept only types that share a common base. You can set an upper bound on a TypeVar so that the type argument must be compatible with that bound.
from typing import TypeVar, Generic from collections.abc import Iterable T = TypeVar("T", bound=Iterable) class Repeater(Generic[T]): def __init__(self, iterable: T) -> None: self._iterable = iterable def repeat(self, times: int) -> list[object]: return list(self._iterable) * times
Here, T must be compatible with Iterable. You can still use Repeater[list[int]] or Repeater[str], but not Repeater[int] because an integer is not iterable. Bounds are useful when the class relies on methods or attributes of the bound. They also make the intent explicit: the class only works with types that provide a certain interface.
Variance and How It Affects Subtyping
Variance determines how generic types relate when their type arguments are themselves subclasses. Python's typing module lets you specify variance on a TypeVar using covariant=True or contravariant=True. By default, type variables are invariant, meaning Box[Cat] is not a subtype of Box[Animal] even if Cat is a subtype of Animal.
from typing import TypeVar, Generic T_co = TypeVar("T_co", covariant=True) class Producer(Generic[T_co]): def __init__(self, value: T_co) -> None: self._value = value def get(self) -> T_co: return self._value
Covariance allows Producer[Cat] to be treated as a subtype of Producer[Animal] because the class only produces values of type T_co. Contravariance, on the other hand, applies to consumers that only accept values. Understanding variance is important when designing generic classes that are meant to be used polymorphically. Most classes that only read from a type parameter should be covariant; those that only write to it should be contravariant. If a class both reads and writes, it must remain invariant.
Runtime Behavior: What Generics Actually Do
Generics in Python are primarily a static typing feature. At runtime, Box[int] and Box[str] do not create separate classes; both refer to the same underlying Box class. Subscripting a generic class produces a type alias that type checkers understand, but it does not change normal attribute access or method behavior.
box = Box[int](42) print(type(box)) # <class '__main__.Box'>
There is no Box[int] class created at runtime. The [] syntax is a hint for type checkers. This has practical implications: if you need runtime type validation, you must implement it separately. You can inspect __orig_class__ for the original parameterization, but generics do not replace runtime checks such as isinstance on stored values. Generics help you catch type errors before the code runs; they are not a runtime safety mechanism.
Maintainability: When Generics Pay Off
Generic classes shine in codebases that reuse the same logic across multiple types. Without generics, you either duplicate the class for each type or use Any, which disables type checking. Both approaches increase maintenance burden. A generic class keeps the implementation in one place while preserving type safety for every instantiation.
Consider a repository pattern that works with different database models. A generic Repository[T] can define common operations like get, save, and delete without knowing the concrete model. Each model gets its own typed repository instance, and type errors surface at development time rather than during a database call.
The tradeoff is added complexity. For a small script or a class used only once, introducing a TypeVar and Generic may be overkill. The decision should be based on how many distinct types the class will serve and how much type safety you need. If you are building a public API or a library that other developers will use, generics are often worth the extra syntax.
Common Mistakes and How to Avoid Them
One frequent mistake is defining a TypeVar but not inheriting from Generic[T]. In the TypeVar-based approach, the TypeVar alone does not make a class generic; you must explicitly inherit from Generic[T] for the type checker to treat it as such. (Python 3.12 also adds the more concise PEP 695 syntax, but this article focuses on the classic TypeVar/Generic form.)
Another mistake is using a concrete type where a type parameter belongs, such as substituting int for T and expecting the class to stay generic. For a reusable generic class, define a TypeVar and use it in Generic[T].
A third issue is ignoring variance when designing class hierarchies. If you mark a TypeVar as covariant but the class also accepts values of that type through a setter, the type checker will raise an error because the class is no longer purely a producer. The solution is to either remove the setter or keep the TypeVar invariant.
Finally, do not expect generics to provide runtime safety. They are a development-time tool. If you need to validate types when the program runs, use isinstance or a validation library. Generics and runtime validation solve different problems, and combining them appropriately leads to more robust code.