Migrating a TypeVar Generic to PEP 695 Syntax

TL;DR

To migrate, move each module-level TypeVar into the brackets of the class, function, or alias that uses it (class Repo[T]), drop the Generic[T] base and the typing import, and convert aliases to the type X[T] = ... statement. Let ruff rules UP046 and UP047 rewrite the mechanical cases. This is Python 3.12+ only — there is no __future__ backport, so do not migrate code that must run on 3.11 or earlier.

PEP 695 shipped in Python 3.12 and lets generics declare their type parameters inline instead of via a separate TypeVar object. Migrating is almost entirely mechanical, but it is a runtime change to the syntax — not just an annotation tweak — so the order of steps and the version floor matter. This guide walks one generic class and one generic function from the legacy form to the new one, noting what mypy and pyright see at each step. For the full feature reference, see PEP 695 type parameter syntax.

Step 1 — Confirm the runtime floor is 3.12

PEP 695 brackets are parsed only by the 3.12+ compiler. Check requires-python before touching code.

# Python 3.12+ required — this file will not even import on 3.11
# pyproject.toml: requires-python = ">=3.12"
class Repo[T]: ...                   # SyntaxError on 3.11

If any supported runtime is below 3.12, stop here and keep the TypeVar form.

Step 2 — Inventory the legacy generic

Here is the starting point: a module-level TypeVar, a Generic base, and a generic function that share the same T.

# Python 3.11, mypy 1.x — legacy form before migration
from typing import Generic, TypeVar

T = TypeVar("T")

class Repo(Generic[T]):
    def __init__(self, items: list[T]) -> None:
        self._items = items
    def first(self) -> T:
        return self._items[0]

def first(xs: list[T]) -> T:
    return xs[0]

mypy sees one shared T here. After migration each construct gets its own parameter — usually what you want, since the sharing was incidental.

Step 3 — Inline the class parameter

Move T into the class brackets and delete the Generic[T] base. mypy now treats T as scoped to Repo.

# Python 3.12+, mypy 1.x --python-version 3.12
class Repo[T]:                       # no Generic[T]; T is local to Repo
    def __init__(self, items: list[T]) -> None:
        self._items = items
    def first(self) -> T:
        return self._items[0]

The analyzer also re-infers variance from usage; you no longer write covariant=True.

Step 4 — Inline the function parameter

The standalone function gets its own [T]. It is now independent of Repo’s T.

# Python 3.12+, mypy 1.x --python-version 3.12
def first[T](xs: list[T]) -> T:
    return xs[0]

Step 5 — Convert aliases to the type statement

Any TypeAlias that referenced the old TypeVar becomes a generic type statement, which also gets lazy evaluation for free.

# Python 3.12+, mypy 1.x
type Page[T] = tuple[list[T], int]   # replaces: Page = tuple[list[T], int]

Step 6 — Delete the now-dead declaration and let ruff finish

Once nothing references it, remove T = TypeVar("T") and the Generic/TypeVar imports. ruff’s pep695 rules automate most of the above: UP046 rewrites Generic[T] classes to the inline form and UP047 rewrites generic functions. Enable them and run --fix.

# pyproject.toml — turn on the pep695 autofixes (target must be 3.12)
[tool.ruff]
target-version = "py312"

[tool.ruff.lint]
extend-select = ["UP046", "UP047"]   # pyupgrade pep695 rewrites
Runtime vs static analysis This is not a pure annotation change. PEP 695 brackets are real syntax compiled by CPython 3.12, and they build genuine TypeVar objects at class/function creation time (visible via __type_params__). Because it is executed syntax, from __future__ import annotations does not backport it — that import only defers annotation evaluation, and the brackets are not annotations. On 3.11 the file raises SyntaxError at import, before any type checker runs.

Edge cases

  • Shared TypeVar across modules: If two modules genuinely need the same variable identity (rare — usually only for explicit variance reuse), keep the named TypeVar. Inline parameters are always distinct per construct.
  • Constrained TypeVars: TypeVar("T", int, str) becomes [T: (int, str)]; a bound TypeVar("T", bound=Model) becomes [T: Model]. ruff handles both, but verify the parentheses on constraints.
  • ParamSpec/TypeVarTuple: These also gain inline forms ([**P], [*Ts]); see ParamSpec and Concatenate for P.

Common mistakes

  • Leaving Generic[T] in place: class Repo[T](Generic[T]) is redundant and rejected — mypy reports the base as an error and pyright raises reportGeneralTypeIssues. Delete the base.
  • Forgetting --python-version 3.12: Run mypy on a 3.12 target or it parses the brackets as a syntax error. Set python_version = "3.12" under [tool.mypy].
  • Passing bound= inline: [T: Model] is the bound; writing [T(bound=Model)] or keeping the keyword is a SyntaxError. A leftover bound mismatch after a bad rewrite shows up as mypy [type-var] / pyright reportArgumentType.

FAQ

Can ruff do the whole migration unattended? UP046/UP047 cover the mechanical class and function rewrites and the import cleanup. Review the diff for constrained TypeVars and any intentionally shared variables, which ruff leaves alone.

Is the migration reversible? Yes — the inline and TypeVar forms are semantically equivalent for a single construct, so you can revert by re-declaring the TypeVar. The only thing you cannot keep is the 3.12 syntax on 3.11.

Back to PEP 695 Type Parameter Syntax