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. Three separate pieces of the legacy spelling — the from typing import TypeVar, Generic line, the standalone T = TypeVar("T") object, and the Generic[T] base in the class header — collapse into a single bracketed parameter list on the class statement.
# 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.
Both checkers treat the two versions as describing the same generic class, so downstream call sites need no changes: OrderRepository[int]() and repo.add(1) type-check identically before and after. That is the central promise of the migration — it is a spelling change, not a semantic one. You can convert a class and leave every consumer untouched, which is what makes the migration safe to do module-by-module.
# Python 3.12+ (PEP 695), checked with mypy 1.10 / pyright 1.1.370
repo: OrderRepository[int] = OrderRepository()
repo.add(1) # ok
repo.add("nope") # mypy: [arg-type]; pyright: reportArgumentType
reveal_type(repo.all()) # Revealed type is "builtins.list[builtins.int]"
There is one subtlety in the header itself. Under PEP 695 the bracket list may also carry bounds and constraints, so a class whose element type must satisfy an interface converts cleanly:
# Python 3.12+ (PEP 695)
from collections.abc import Hashable
class UniqueStore[T: Hashable]: # was: T = TypeVar("T", bound=Hashable)
def __init__(self) -> None:
self._seen: set[T] = set()
def add(self, item: T) -> None:
self._seen.add(item) # requires T be Hashable — the bound guarantees it
The bound moves from the bound= keyword of the old TypeVar call onto the T: Hashable colon syntax inside the brackets. Passing an unhashable element — UniqueStore[list[int]] — now trips [type-var] in mypy and reportGeneralTypeIssues in pyright at the point of specialization, exactly as the legacy bound did. Multiple parameters are comma-separated: class Mapping[K: Hashable, V] declares two independent, individually-scoped parameters. Note that PEP 695 does not require you to list parameters in the order they first appear in the body, but keeping declaration order aligned with usage keeps error messages readable. See the parent PEP 695 syntax reference for the full grammar of bounds versus constraints.
Generic @dataclass and NamedTuple classes migrate with the identical edit — the bracket list sits on the class header and the decorator or base is untouched. @dataclass class Box(Generic[T]) becomes @dataclass class Box[T], and the synthesized __init__, __eq__, and __repr__ stay fully typed because the decorator runs after the interpreter has bound the class’s __type_params__. TypedDict is the one exception worth flagging: class Movie[T](TypedDict) is valid on 3.12+, but many runtime TypedDict consumers still read the legacy Generic machinery, so verify your validator before converting a public schema. Protocols follow the class rule exactly — class SupportsRead[T](Protocol) keeps the Protocol base (which is not Generic) and simply adds [T].
Before and after: a generic function
Functions migrate the same way — the parameter moves from a module-level object into [...] on the def header. The table below lines up each legacy construct against its PEP 695 replacement so the mechanical edits are visible at a glance.
# 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)](...). Watch the punctuation: a bound uses a bare type after the colon ([K: Hashable], meaning “any subtype of Hashable”), whereas constraints use a parenthesized tuple ([N: (int, float)], meaning “exactly int or exactly float, nothing in between”). Writing [N: int, float] without the parentheses is a SyntaxError at compile time, not a type error — the interpreter itself rejects it before mypy or pyright ever runs.
The scoping change matters most when a function reuses a name. Under the legacy form, a module-level E = TypeVar("E") shared by two functions is literally the same object; the checker still treats each function’s solve independently, but the shared name invites confusion and accidental reuse in nested scopes. PEP 695’s [E] is genuinely local — the E inside first_or_none is a different variable from an E inside any other function, so there is no shared-state footgun:
# Python 3.12+ (PEP 695), checked with mypy 1.10 / pyright 1.1.370
def first_or_none[E](items: Iterable[E]) -> E | None: ...
def last_or_none[E](items: Iterable[E]) -> E | None: ... # this E is unrelated to the one above
At runtime the two spellings differ in one observable way: a PEP 695 function exposes its parameters through the new __type_params__ attribute, a tuple of the implicitly-created TypeVar objects, while a legacy function has no such attribute (its TypeVar lived at module scope). first_or_none.__type_params__ returns (E,); the object inside is a real typing.TypeVar created lazily by the interpreter, so introspection libraries can still recover the parameter — they just have to look in a new place. If you need a named, importable, reusable variable — for example to share one parameter across a function and a module-level type alias — keep the legacy TypeVar; the two systems interoperate freely within a file. PEP 695 also introduces a companion feature, TypeVar defaults via PEP 696, spelled inline as def f[T = int](...), which the legacy syntax cannot express before typing_extensions 4.4.
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. The decision the checker runs for each parameter follows a fixed rule based on its positions.
# 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.
Inference also means the variance can change as you edit the class, and both checkers will follow. If you add a setter to Producer, T now appears in an input position, so it is re-inferred as invariant — and a caller that relied on the old covariance breaks:
# Python 3.12+ (PEP 695), checked with mypy 1.10 / pyright 1.1.370
class Producer[T]:
def get(self) -> T: ...
def put(self, value: T) -> None: ... # T now input AND output -> invariant
ints: Producer[int] = Producer()
widen: Producer[object] = ints # mypy: [assignment]; pyright: reportAssignmentType
# Producer[int] is no longer a subtype of Producer[object] once T is invariant
That diagnostic is correct, not a regression: a read-write container genuinely cannot be covariant without breaking soundness, and PEP 695 simply stops you from mislabelling it. Under the legacy syntax you could have written TypeVar("T", covariant=True) and mypy would have flagged the covariant parameter in the put argument position with [misc] (“Cannot use a covariant type variable as a parameter”); the inference form removes the ability to make that mistake in the first place. When you truly need to override the inferred variance — a rare case, usually for compatibility with an external generic — you can still fall back to a legacy TypeVar with an explicit covariant=/contravariant= flag for that one parameter, since legacy and inline parameters interoperate in the same class body. See variance and type parameters for when forcing variance is justified.
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
Most migrations are mechanical, but four checks decide whether a given class or module is actually ready to convert. Walk them in order — the runtime floor gate comes first because it is the only one that fails at import time rather than in the type checker.
- 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. The same applies to inheriting from another generic base: a PEP 695 class can subclass a generic parent, but you re-parametrize it with the inline form —class IntRepo(OrderRepository[int])— never by re-addingGeneric. - 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. A quickgrepfor theTypeVarname across the module tells you whether theT = TypeVar("T")line can actually be deleted; removing it while another class still references it is a plain[name-defined]/reportUndefinedVariableerror at import. - 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. Because a PEP 695 class no longer listsGeneric[T]in its MRO,typing.get_type_hintsstill works butcls.__orig_bases__no longer carries the parameter — code doingget_args(cls.__orig_bases__[0])returns nothing. Pydantic v2, attrs, and SQLAlchemy’s typed mappings read__type_params__on recent releases, but pin-check before converting a model base class. - 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, unlike deferred annotations.from __future__ import annotationsonly postpones annotation evaluation; it does nothing for theclass Repo[T]header, which is real grammar the 3.11 parser cannot read. If any supported runtime is still 3.11, keep the legacyTypeVarform and defer the migration for that module. Configure the checker to match: setpython_version = "3.12"for mypy (see mypy strictness configuration) and an accuratepythonVersionfor pyright, or the checker will disagree with the interpreter about whether the syntax is even legal.
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.