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 changed the CPython grammar, not just the typing module. The square-bracket type-parameter
list on a class, def, or type statement is new syntax that only the 3.12 parser understands, so
the very first thing to establish is your minimum supported interpreter. This is different from almost
every other typing feature you have migrated before: Self, LiteralString, override, and friends
are ordinary names importable from typing_extensions on old runtimes, but there is no
typing_extensions shim for a grammar change. A file containing class Repo[T]: ... raises
SyntaxError on 3.11 at compile time — before the module body runs, before any import executes, and
long before a type checker is invoked. That failure mode is total: a single migrated module poisons
the whole package for 3.11 users because the import machinery cannot even produce a code object.
# 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
Check requires-python in pyproject.toml and, just as importantly, your CI matrix. If GitHub
Actions still runs a 3.11 job, that job will fail at collection time the moment you merge inline
generics — a red build that has nothing to do with your tests. A library on PyPI has a harder
constraint than an application: your requires-python becomes a hard gate that pip enforces at install
time, so bumping it to >=3.12 drops every downstream user still on 3.11. For libraries that must keep
supporting 3.11, do not migrate the runtime syntax yet; you can adopt PEP 695 semantics only in
.pyi stub files (which are never executed) while the runtime .py keeps the TypeVar form.
A common misconception is that from __future__ import annotations (PEP 563) unlocks the new syntax.
It does not. That import stringizes annotations so they are not evaluated at runtime, but the bracket
list on a class/def is not an annotation — it is a header the parser must accept regardless. There
is no __future__ flag that backports grammar. If any supported runtime is below 3.12, stop here and
keep the TypeVar form.
Step 2 — Inventory the legacy generic
Before you change a line, map out which constructs consume the shared TypeVar and whether the
sharing is meaningful. In the legacy model a TypeVar is a free-standing object bound to a module
name; every class, function, or alias that mentions T refers to that one object. That single
identity is a source of subtle coupling. Here is the starting point: a module-level TypeVar, a
Generic base, and a generic function that all reference 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]
The key insight for the migration is that this shared identity is almost always incidental. A type
checker never links Repo’s T to first’s T in a way you can observe — each generic scope binds
the variable independently at each use, so Repo[int]().first() and first([1]) were never
constrained to agree. The one place identity genuinely matters is explicit variance: two constructs
that must reuse a TypeVar("T_co", covariant=True) to interoperate. That case is rare, and PEP 695
handles it differently (variance is inferred, see Step 3), so treat every shared TypeVar as a
candidate for splitting until you find a concrete reason not to.
Grep the module for the variable name to build your inventory. rg '\bT\b' (or your editor’s
find-references) tells you exactly how many brackets you will fill. Note anything unusual now: a
TypeVar with bound=, one with a constraint tuple, a ParamSpec, or a TypeVarTuple — each has a
distinct inline spelling covered in the Edge cases section. Also record which names are re-exported;
if T is imported by other modules (from .repo import T), those import sites break when you delete
the declaration in Step 6, so they must be migrated in lockstep.
Step 3 — Inline the class parameter
Move T into the class brackets and delete the Generic[T] base. The bracket list creates a fresh,
lexically scoped type parameter that is visible throughout the class body — method signatures,
nested functions, and default expressions — without any import. Repo still ends up in Generic’s
MRO (CPython synthesizes the base for you), so Repo.__mro__ and isinstance/issubclass behavior
are unchanged; what changes is that T is now owned by Repo rather than by the module.
# 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]
At runtime the compiler builds a real TypeVar object and hangs it off the new __type_params__
attribute. You can inspect it — this is the executable proof that PEP 695 is syntax, not annotation
sugar:
>>> Repo.__type_params__
(T,)
>>> Repo.__type_params__[0].__name__
'T'
>>> from typing import TypeVar
>>> isinstance(Repo.__type_params__[0], TypeVar)
True
The other behavioral shift is variance inference. Under the legacy model you declared variance by
hand (TypeVar("T_co", covariant=True)) and a mismatch produced mypy’s [misc] error
“Cannot use a covariant type variable as a parameter”. With PEP 695 you never pass covariant= or
contravariant=; the checker infers each parameter’s variance from how the class uses it — output-only
positions infer covariance, input-only positions infer contravariance, and mixed use stays invariant.
mypy implements this on a --python-version 3.12 target and pyright does the same by default. The
practical upshot: drop the _co/_contra naming convention entirely, and let a construct that reads
like a covariant container simply be inferred covariant. Run mypy with --python-version 3.12
here — on a lower target it rejects the brackets as a parse error before variance ever enters the
picture.
Step 4 — Inline the function parameter
The standalone function gets its own [T], placed between the function name and the parameter list.
This is the clearest demonstration of PEP 695’s per-construct scoping: first’s T and Repo’s
T are now genuinely separate objects that merely happen to share a spelling. Each call site infers
the function’s T fresh from its argument, exactly as before, but there is no longer any module-level
name that could accidentally tie the two together.
# Python 3.12+, mypy 1.x --python-version 3.12
def first[T](xs: list[T]) -> T:
return xs[0]
The function grows its own __type_params__ tuple, independent of the class:
>>> first.__type_params__
(T,)
>>> first.__type_params__[0] is Repo.__type_params__[0]
False # different objects — scoping is real
This independence is why the migration is safe to do construct by construct: you can inline first
in one commit and Repo in another without either observing the other’s change. It also removes a
class of ordering bug from the legacy form, where a generic function defined before its
module-level TypeVar (or importing it from a not-yet-imported module) could raise NameError.
With inline parameters the name exists only inside the header, so there is nothing to define first
and nothing to import. Both mypy --python-version 3.12 and pyright accept the inline function
verbatim; neither needs a Generic base for functions, which never had one anyway.
Step 5 — Convert aliases to the type statement
Any alias that referenced the old TypeVar becomes a generic type statement. This is where PEP 695
does more than tidy syntax: the type statement is lazily evaluated. The right-hand side is not
computed when the statement runs — it is wrapped in a typing.TypeAliasType object whose value is
computed only on first access via the .__value__ property.
# Python 3.12+, mypy 1.x
type Page[T] = tuple[list[T], int] # replaces: Page = tuple[list[T], int]
Inspecting the alias shows the machinery. Page is no longer a bare tuple-typing object; it is a
TypeAliasType that remembers its own type parameters and defers its definition:
>>> type(Page)
<class 'typing.TypeAliasType'>
>>> Page.__type_params__
(T,)
>>> Page.__value__ # forced here, computed on demand
tuple[list[T], int]
>>> Page[int] # subscription still works
Page[int]
Lazy evaluation is not cosmetic — it fixes real problems. A legacy Page = tuple[list[T], int] is an
eager assignment, so any name on the right must already exist; forward references to a class defined
later in the file forced you to quote the whole thing as a string. The type statement defers
evaluation, so type Node[T] = tuple[T, "Node[T]"] and mutually recursive aliases resolve without
manual string-quoting. It also gives the checker an unambiguous signal that this is a type alias
rather than an ordinary variable that merely holds a type — the same disambiguation you previously
spelled out with Page: TypeAlias = .... mypy and pyright both understand the type statement
natively; ruff’s UP040 rewrites the older TypeAlias-annotated form to it. One caveat worth
knowing: because the value is lazy, a genuinely undefined name on the right is reported only when the
alias is first used, not at definition — so keep an eye on [name-defined] errors surfacing at the
use site.
Step 6 — Delete the now-dead declaration and let ruff finish
Once nothing references it, remove T = TypeVar("T") and prune the now-unused Generic/TypeVar
imports. Do this last: deleting the declaration while any construct still refers to T produces a
cascade of [name-defined] errors, so the dead-code removal is the closing move, not the opening one.
Rather than editing by hand, lean on ruff — its pep695 rules automate almost the entire migration and
guarantee the mechanical cases are consistent. UP046 rewrites Generic[T] classes to the inline
class Repo[T] form; UP047 rewrites generic functions to def first[T](...); UP040 converts
TypeAlias-annotated aliases to the type statement; and once those rewrites land, ruff’s own
unused-import rule (F401) removes the orphaned from typing import Generic, TypeVar. Enable the
rules, target 3.12, 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
Most generics are a plain TypeVar and migrate in one bracket, but the legacy typing vocabulary has
several specialized declarations, and each has an exact inline counterpart you should recognize on
sight. The mapping below covers every shape you are likely to meet.
- Constrained vs bound TypeVars: A bound
TypeVar("T", bound=Model)— meaning “any subtype ofModel” — becomes[T: Model]. A constrainedTypeVar("T", int, str)— meaning “exactlyintor exactlystr, no subtypes, no common supertype” — becomes[T: (int, str)]with mandatory parentheses. The two are semantically different (a bound admits subclasses; constraints do not), and the parentheses are the only syntactic signal that distinguishes[T: (int, str)](constraints) from a hypothetical bound. Dropping them changes meaning, so ruff’s rewrite keeps them and you should double-check the diff. A bound can itself be generic or a union:[T: (Sequence[int] | None)]. ParamSpecandTypeVarTuple: These gain inline forms[**P]and[*Ts]respectively, and can be mixed with ordinary parameters in one bracket, e.g.class Handler[T, **P, *Ts]. The ordering rules from the legacyGeneric[...]still apply. See ParamSpec and Concatenate for howPcomposes withConcatenate, and PEP 695 type parameter syntax for the full grammar.- Shared
TypeVaracross modules: If two modules genuinely need the same variable identity (rare — it only matters when you rely on object identity, which PEP 695 removes), keep the namedTypeVar. Inline parameters are always distinct per construct, so there is no way to express a cross-construct sharedTinline; that is a deliberate design choice, not a gap. - Defaults (PEP 696, 3.13): A
TypeVar("T", default=int)maps to[T = int], but only on Python 3.13+ where PEP 696 landed. On 3.12 that spelling is aSyntaxError, so a construct using a default raises the runtime floor one minor version further than the rest of the migration.
Common mistakes
The migration is mechanical, but a handful of errors recur often enough to be worth naming, each with the exact checker diagnostic it produces so you can recognize it in CI output.
- Leaving
Generic[T]in place:class Repo[T](Generic[T])double-declares the parameter and is rejected. mypy flags it as a[misc]error (“Generic[…] base class is redundant”) and pyright raisesreportGeneralTypeIssues. Delete the base entirely — the bracket already putsRepoinGeneric’s MRO for you. - Forgetting the 3.12 target: mypy parses source against a configured Python version, and its
default may be the interpreter it runs under. On a sub-3.12 target it treats the brackets as a
[syntax]error before any real analysis. Pass--python-version 3.12or setpython_version = "3.12"under[tool.mypy]; pyright readspythonVersionfrompyrightconfig.jsonor[tool.pyright]. - Mis-spelling bounds and constraints:
[T: Model]is a bound;[T(bound=Model)]keeps the legacy keyword and is aSyntaxError, and[T: int, str](constraints without parentheses) is also aSyntaxError— constraints require[T: (int, str)]. A bound that no longer matches after a botched rewrite surfaces as mypy[type-var](“Value of type variable is not a subtype of the bound”) or pyrightreportArgumentTypeat each offending subscription; a malformed bound expression itself is mypy[valid-type]. - Carrying over
covariant=/contravariant=: there is no inline syntax for explicit variance, and trying to keep the keyword is aSyntaxError. Drop it and let the checker infer variance from usage (see Step 3); if inference disagrees with your old hand-annotation, the fix is to change how the parameter is used, not to re-add a keyword. - Deleting the
TypeVartoo early: removingT = TypeVar("T")before every reference is inlined leaves live references to a vanished name, reported as mypy[name-defined]and pyrightreportUndefinedVariable. Do the deletion last, as in Step 6.
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.