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 PEP 695 migration The legacy form declares a module-level TypeVar and inherits Generic of T; the PEP 695 form writes the parameter inline as class Repo of T with the TypeVar and Generic base removed. legacy T = TypeVar("T") class Repo(Generic[T]): module-level + Generic base PEP 695 class Repo[T]: scoped, variance inferred no TypeVar, no Generic 3.12+
Migration folds a module-level TypeVar and the Generic base into a single inline type parameter.

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.

Runtime vs static analysis PEP 695 changes the runtime too, unlike most typing features. 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 reports reportGeneralTypeIssues, mypy [misc]. Pick one spelling per class; the inline [T] already makes it generic, so the Generic base is redundant and rejected.
  • A leftover shared TypeVar used elsewhere: if the module-level T was 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 from Generic bases (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 no from __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.

Back to PEP 695 Type Parameter Syntax