Migrating a TypeVar Generic to PEP 695 Syntax
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
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
TypeVaracross modules: If two modules genuinely need the same variable identity (rare — usually only for explicit variance reuse), keep the namedTypeVar. Inline parameters are always distinct per construct. - Constrained TypeVars:
TypeVar("T", int, str)becomes[T: (int, str)]; a boundTypeVar("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 forP.
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 raisesreportGeneralTypeIssues. Delete the base. - Forgetting
--python-version 3.12: Run mypy on a 3.12 target or it parses the brackets as a syntax error. Setpython_version = "3.12"under[tool.mypy]. - Passing
bound=inline:[T: Model]is the bound; writing[T(bound=Model)]or keeping the keyword is aSyntaxError. A leftover bound mismatch after a bad rewrite shows up as mypy[type-var]/ pyrightreportArgumentType.
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.