Migrating Generic Classes to PEP 695 Syntax
TL;DR — On Python 3.12+ you can replace class Repo(Generic[T]) plus a module-level TypeVar with inline class Repo[T]. The type parameter becomes scoped to the class, variance is inferred automatically, and you drop the Generic base and the standalone TypeVar object. The old and new spellings cannot be mixed for the same parameter, and at runtime the parameters are reachable via Repo.__type_params__.
PEP 695 introduced a compact, lexically-scoped way to declare type parameters directly on the class or def header. Migrating removes a whole category of boilerplate — the module-level TypeVar declarations, explicit variance= flags, and the Generic base — while keeping behavior identical for checkers. This page, part of Advanced Typing Patterns & Generics, gives before/after conversions for a class and a function and flags the migration pitfalls.
Before and after: a generic class
The legacy OrderRepository declares a TypeVar at module scope and inherits Generic[T]. The PEP 695 version writes [T] on the header and deletes both.
# Python 3.8+ (legacy), checked with mypy 1.10 / pyright 1.1.370
from typing import TypeVar, Generic
T = TypeVar("T")
class OrderRepository(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> None:
self._items.append(item)
def all(self) -> list[T]:
return self._items
# Python 3.12+ (PEP 695), checked with mypy 1.10 / pyright 1.1.370
class OrderRepository[T]:
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> None:
self._items.append(item)
def all(self) -> list[T]:
return self._items
T is now scoped to OrderRepository — it exists only inside the class body and cannot leak to another class the way a shared module-level TypeVar could. The import of TypeVar and Generic disappears entirely if this was their only use.
Before and after: a generic function
Functions migrate the same way — the parameter moves from a module-level object into [...] on the def header.
# Python 3.8+ (legacy), checked with mypy 1.10
from typing import TypeVar
from collections.abc import Iterable
E = TypeVar("E")
def first_or_none(items: Iterable[E]) -> E | None:
for item in items:
return item
return None
# Python 3.12+ (PEP 695), checked with mypy 1.10
from collections.abc import Iterable
def first_or_none[E](items: Iterable[E]) -> E | None:
for item in items:
return item
return None
Bounds and constraints carry over inline too: TypeVar("K", bound=Hashable) becomes def f[K: Hashable](...), and TypeVar("N", int, float) becomes def f[N: (int, float)](...).
Variance is inferred, not declared
Under the legacy syntax you chose variance explicitly with TypeVar("T_co", covariant=True). PEP 695 infers variance from how each parameter is used: a parameter only ever produced (returned) is inferred covariant, one only consumed (accepted) contravariant, and one both produced and consumed invariant. You simply write [T] and let the checker decide.
# Python 3.12+ (PEP 695), checked with pyright 1.1.370
class Producer[T]: # T only returned -> inferred covariant
def get(self) -> T: ...
class Sink[T]: # T only accepted -> inferred contravariant
def put(self, value: T) -> None: ...
This removes the most common variance bug: hand-declaring covariant=True on a parameter that is actually mutated, which the old checker accepted only if you also happened to keep it read-only.
class OrderRepository[T] creates real TypeVar objects lazily and exposes them as OrderRepository.__type_params__; the class no longer inherits from Generic in its MRO the way the legacy form did. Code that inspected __orig_bases__ or relied on a module-level T object being importable will break — the scoped parameter is not a module global.
Migration pitfalls
- Mixing old and new for the same parameter: you cannot write
class Repo[T](Generic[T])— pyright reportsreportGeneralTypeIssues, mypy[misc]. Pick one spelling per class; the inline[T]already makes it generic, so theGenericbase is redundant and rejected. - A leftover shared TypeVar used elsewhere: if the module-level
Twas reused by several classes, converting one class to[T]does not remove the others’ dependency. Migrate each user or keep the object until all are converted. - Runtime introspection on
__orig_bases__: frameworks that read type arguments fromGenericbases (some serializers, DI containers) may not yet understand__type_params__; verify library support before migrating public classes. - Targeting a pre-3.12 runtime: the
[T]syntax is a syntax error on Python 3.11 and below — there is nofrom __future__opt-in. Only migrate code whose minimum runtime is 3.12.
FAQ
Can I migrate incrementally, one class at a time?
Yes — PEP 695 and legacy generics coexist across a codebase, and a checker treats an inline class Repo[T] and a legacy class Store(Generic[U]) identically. The only hard rule is you cannot mix the two spellings for a single parameter. Convert file by file as each module’s runtime floor reaches 3.12.
Do I lose the ability to set variance explicitly?
For the common cases, inference is what you want and is safer. When you genuinely need to force a variance the inference would not pick, you can still fall back to a legacy TypeVar with covariant=/contravariant= for that specific parameter, since the two systems interoperate.