Back to Blog
Python

Python Generic Class Syntax

python generic class syntax: Learn how to define generic classes in Python using TypeVar and the new PEP 695 syntax, with practical examples and common pitfalls.

pythongenericstype-hintstypevarpep-695
Illustration of a Python generic class with type parameters shown as abstract boxes.

python generic class syntax requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

Python's generic class syntax lets you define classes that are parameterized by one or more types, so you can write reusable components without losing type information. The classic approach uses TypeVar from the typing module, while Python 3.12 introduced a more concise built-in syntax. This article covers both, explains how they differ, and shows where each fits.

The Classic TypeVar Syntax

Before Python 3.12, the standard way to declare a generic class was to create a TypeVar and pass it to the Generic base class. Here is a minimal example:

from typing import TypeVar, Generic T = TypeVar("T") class Stack(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop()

The TypeVar declares a placeholder type. When you use Stack[int], the type checker substitutes int for T throughout the class body. This gives you type-safe methods without writing separate implementations for each type.

The Generic[T] base class signals that the class is generic and which type variables it uses. You can also use multiple type variables, for example class Pair(Generic[K, V]).

The New PEP 695 Syntax

Python 3.12 introduced a more direct syntax for generics, based on the type statement and type parameter lists. You can now write:

class Stack[T]: def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop()

The [T] after the class name declares the type parameter. No TypeVar or Generic import is needed. This syntax is more readable and reduces boilerplate, especially for classes with several type parameters.

PEP 695 also allows type parameter lists on functions and type aliases, but the class syntax is the focus here. The type parameter is in scope for the entire class body, including methods and nested classes.

Comparing the Two Syntaxes

Both syntaxes produce the same runtime type object, but they differ in how the type checker interprets them and in the code you write.

AspectTypeVar + GenericPEP 695 class C[T]
Python version3.5+3.12+
BoilerplateRequires TypeVar and Generic importsNone
ReadabilityMore verbose, especially with multiple parametersConcise and clear
Type parameter scopingGlobal to module, must be uniqueScoped to the class
Defaults and boundsUse bound= and covariant= in TypeVarUse T: bound or T = default in the parameter list

For new code targeting Python 3.12 or later, the PEP 695 syntax is usually the better choice. It is shorter and keeps type parameters local to the class. If you need to support older Python versions, the TypeVar approach remains the only option.

Using Generic Classes in Real Code

Generic classes shine when you need a data structure or service that operates on multiple types while preserving type safety. For example, a repository pattern often benefits from a generic base class:

from typing import TypeVar, Generic from dataclasses import dataclass T = TypeVar("T") @dataclass class Entity: id: int class Repository(Generic[T]): def __init__(self) -> None: self._store: dict[int, T] = {} def add(self, entity: T) -> None: self._store[entity.id] = entity def get(self, entity_id: int) -> T | None: return self._store.get(entity_id)

When you instantiate Repository[User], the type checker knows that add expects a User and get returns User | None. This catches mistakes like passing a Product to a Repository[User] at compile time.

Generic classes also work well with protocols and abstract base classes. You can constrain the type parameter to a specific protocol, which we cover next.

Constraints and Bounds

Sometimes you need to restrict what types can be used as type arguments. For example, you might want a generic class that only works with numeric types. The TypeVar approach uses the bound parameter:

from typing import TypeVar, Generic from numbers import Number N = TypeVar("N", bound=Number) class Summable(Generic[N]): def __init__(self, values: list[N]) -> None: self._values = values def total(self) -> N: return sum(self._values)

Here, N can only be a subtype of Number, so Summable[int] and Summable[float] are allowed, but Summable[str] is rejected by the type checker.

The PEP 695 syntax uses a similar bound syntax:

from numbers import Number class Summable[N: Number]: def __init__(self, values: list[N]) -> None: self._values = values def total(self) -> N: return sum(self._values)

You can also specify a default type parameter in PEP 695, like class Box[T = int], which is not directly possible with TypeVar.

Common Mistakes and Misunderstandings

One frequent mistake is trying to use isinstance with a parameterized generic type:

# This will raise TypeError at runtime isinstance(obj, Stack[int])

Generic types are erased at runtime. Stack[int] is not a distinct class; it is just Stack with type information attached for the type checker. To check the base class, use isinstance(obj, Stack).

Another issue is forgetting to declare type parameters on methods. In the TypeVar approach, a method that uses T but is not part of the class body will not see the class-level TypeVar. In PEP 695, the type parameter is in scope for the whole class, so this is less error-prone.

A third mistake is using a TypeVar that is not actually generic. If you define T = TypeVar("T") but never use it in the class, the class is not generic. The type checker will not infer any type parameters.

Runtime Behavior and Type Checking

Generic classes are primarily a type-checking construct. At runtime, the class object is the same regardless of the type arguments. The __class_getitem__ method is used to create parameterized versions, but these are not new classes. This means generic classes have no runtime overhead beyond the normal class definition.

Type checkers like mypy and Pyright understand both syntaxes, but they enforce the rules differently. For example, mypy fully supports PEP 695 only from version 1.8 onward. If you are using an older type checker, the classic TypeVar syntax is safer.

When you subclass a generic class, you need to propagate the type parameter explicitly. With the classic syntax:

class IntStack(Stack[int]): pass

With PEP 695, you can also write class IntStack(Stack[int]), but you can also keep the parameter open:

class AnotherStack[T](Stack[T]): pass

This is useful when you want to add functionality while preserving the generic behavior.

Understanding how generics behave at runtime helps you avoid surprises when using reflection, serialization, or isinstance checks. The type parameters exist only in the type system, not in the runtime object model.

python generic class syntax: Practical Usage and Code Exampl | RYUSLOG DEV