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 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.

PEP 695 runtime floor on the version line Inline type-parameter syntax parses only on Python 3.12 and later; 3.11 and earlier raise SyntaxError. Where inline class Repo[T] is a valid parse SyntaxError at import PEP 695 available 3.8 3.9 3.10 3.11 3.12 runtime floor 3.13
The bracket syntax is a grammar change: 3.11 and older cannot parse it at all.

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.

One shared TypeVar feeds two constructs A single module-level TypeVar object is referenced by both the class and the function, but the binding is incidental. T = TypeVar("T") one module-level object referenced by referenced by class Repo(Generic[T]) binds T in its own scope def first(xs: list[T]) binds T independently
Both consume the same object, yet the checker never links the two scopes — the sharing is safe to split.

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.

Inlining the class type parameter The Generic base and module-level TypeVar collapse into a single bracket list owned by the class. before T = TypeVar("T") class Repo(Generic[T]): import + module TypeVar + base after class Repo[T]: T scoped to Repo · variance inferred Repo.__type_params__ == (T,)
Three lines of ceremony collapse into one bracket the class owns.

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.

Per-construct scoping after inlining The class and the function each own a separate T; the objects are not identical. No module-level T — each header owns its own class Repo[T] own T · own __type_params__ scope: Repo def first[T] own T · own __type_params__ scope: first ✗ not the same object
Same letter, two separate parameters — no shared identity to reason about.

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.

Alias becomes a lazy TypeAliasType The eager assignment becomes a type statement wrapped in a TypeAliasType whose value evaluates on first access. eager assignment Page = tuple[list[T], int] RHS evaluated immediately forward refs need quotes lazy type statement type Page[T] = tuple[list[T], int] TypeAliasType · __type_params__ == (T,) .__value__ computed on first access
The right-hand side is deferred, so recursive and forward-referencing aliases stop needing quotes.

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.

ruff --fix migration pipeline Three pep695 rewrite rules feed the inline generics, after which the unused-import rule deletes the dead TypeVar imports. ruff check --fix (target-version = py312) UP046 Generic[T] class UP047 generic function inline [T] syntax UP040 also rewrites aliases F401 drops dead imports
The rewrite rules run first; the unused-import sweep finishes by deleting the orphaned TypeVar import.
# 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

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.

Legacy declaration to inline form mapping Each legacy TypeVar, ParamSpec and TypeVarTuple declaration maps to a specific PEP 695 bracket spelling. legacy declaration PEP 695 inline form TypeVar("T") [T] TypeVar("T", bound=Model) [T: Model] TypeVar("T", int, str) [T: (int, str)] ParamSpec("P") [**P] TypeVarTuple("Ts") [*Ts]
Every legacy declaration has one canonical bracket spelling — memorize the five rows.
  • Constrained vs bound TypeVars: A bound TypeVar("T", bound=Model) — meaning “any subtype of Model” — becomes [T: Model]. A constrained TypeVar("T", int, str) — meaning “exactly int or exactly str, 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)].
  • ParamSpec and TypeVarTuple: 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 legacy Generic[...] still apply. See ParamSpec and Concatenate for how P composes with Concatenate, and PEP 695 type parameter syntax for the full grammar.
  • Shared TypeVar across 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 named TypeVar. Inline parameters are always distinct per construct, so there is no way to express a cross-construct shared T inline; 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 a SyntaxError, 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.

Wrong versus right migration patterns Common migration mistakes on the left paired with their correct inline form on the right. ✗ wrong ✓ right class Repo[T](Generic[T]) class Repo[T] [T(bound=Model)] [T: Model] [T: int, str] [T: (int, str)] mypy default target (3.11) --python-version 3.12 TypeVar("T", covariant=True) [T] (variance inferred)
The left column is what a hand migration slips into; the right column is what ruff produces.
  • 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 raises reportGeneralTypeIssues. Delete the base entirely — the bracket already puts Repo in Generic’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.12 or set python_version = "3.12" under [tool.mypy]; pyright reads pythonVersion from pyrightconfig.json or [tool.pyright].
  • Mis-spelling bounds and constraints: [T: Model] is a bound; [T(bound=Model)] keeps the legacy keyword and is a SyntaxError, and [T: int, str] (constraints without parentheses) is also a SyntaxError — 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 pyright reportArgumentType at 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 a SyntaxError. 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 TypeVar too early: removing T = TypeVar("T") before every reference is inlined leaves live references to a vanished name, reported as mypy [name-defined] and pyright reportUndefinedVariable. 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.

Back to PEP 695 Type Parameter Syntax