PEP 695: Inline Type Parameter Syntax for Generics

PEP 695 landed in Python 3.12 and gives generics a dedicated, declaration-free syntax: you write the type parameter in square brackets right on the class, function, or alias — class Repo[T], def first[T](xs: list[T]) -> T, type Alias[T] = list[T] — instead of declaring a module-level TypeVar first. The parameter is scoped lexically to the construct that introduces it, its variance is inferred rather than declared, and the new type statement evaluates its body lazily. This guide covers the full syntax, how mypy and pyright check it, and how it differs from the legacy form. For the broader picture, see Advanced Typing Patterns & Generics.

Legacy TypeVar versus PEP 695 inline syntax Legacy code declares a TypeVar at module scope then references it; PEP 695 declares the parameter inline on the class. Legacy (3.8–3.11) T = TypeVar("T") module scope class Repo(Generic[T]): def get(self) -> T: ... references the global T PEP 695 (3.12+) class Repo[T]: def get(self) -> T: ... T scoped to the class
PEP 695 folds the separate TypeVar declaration into the construct that uses it.

Syntax spec: the three generic forms

PEP 695 attaches a type parameter list directly to a class, function, or alias. There are exactly three places a [...] list may appear — after a class name, after a def name, and after the target of a type statement — and in every case the effect is the same: it introduces one or more type parameters scoped to that construct, with no import, no Generic base, and no separately declared TypeVar object.

The three PEP 695 generic forms A stack of three rows showing the class form, the function form, and the type-alias form, each introducing a bracketed type parameter. 1. class class Repo[T]: … T scoped to the class body 2. def def first[T](xs: list[T]) -> T: … T scoped to the function 3. type type Pair[T] = tuple[T, T] lazy TypeAliasType
Each form binds its own parameter; the same [T] spelling works on classes, functions, and aliases.
# Python 3.12+, mypy 1.x / pyright
class Repo[T]:                       # generic class — no Generic[T] base needed
    def __init__(self, items: list[T]) -> None:
        self._items = items
    def first(self) -> T:
        return self._items[0]

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

type Pair[T] = tuple[T, T]           # generic type alias via the new `type` statement

The implicit type variable created by [T] is a genuine typing.TypeVar at runtime, but it is lazily created and never bound to a module-level name. You reach it through the construct’s __type_params__ attribute — Repo.__type_params__ returns (~T,), and the same works on functions and on the TypeAliasType produced by type Pair[T] = .... Because the parameter has no module global, T in Repo and T in first are different objects even though they share a spelling; this is the mechanical basis for the lexical scoping discussed below.

A single construct can take several parameters, and they may be ordinary type parameters, a ParamSpec written [**P], or a TypeVarTuple written [*Ts]:

# Python 3.12+, mypy 1.x / pyright
class Handler[T, **P, *Ts]:          # TypeVar, ParamSpec, TypeVarTuple in one list
    ...

def wrap[**P, R](fn: Callable[P, R]) -> Callable[P, R]:  # PEP 612 inline
    ...

The [**P]/[*Ts] spellings are the PEP 695 equivalents of importing ParamSpec and TypeVarTuple, and they compose with bounds and defaults exactly like a plain parameter. Ordering follows the usual rule: a TypeVarTuple or a defaulted parameter must not be followed by a non-defaulted one.

Bounds and constraints

A parameter list slot can be restricted in two mutually exclusive ways. A bound ([T: Model]) says T may be Model or any subtype of it, and the checker treats a value of type T as having at least Model’s interface. A constraint set ([T: (int, str)]) says T must resolve to exactly one of the listed types — no subtypes, no unions — and every use of T in that scope is solved to a single member. Bounds widen; constraints enumerate.

Bound versus constraint set A root question branches to a bound, which admits any subtype, or a constraint set, which admits exactly one listed type. restrict T how? [T: Model] [T: (int, str)] bound: any subtype OK constraint: exactly one of Admin, User, Model itself int OR str, never bool-as-int
A bound admits the whole subtype cone; a constraint set admits only the exact members listed.
# Python 3.12+, mypy 1.x / pyright
from collections.abc import Sized

class Model: ...

def save[T: Model](row: T) -> T:           # upper bound: T must be a Model subtype
    return row

def width[T: (int, str)](value: T) -> T:   # constraints: T is exactly int OR exactly str
    return value

def length[T: Sized](value: T) -> int:     # bound on a Protocol works too
    return len(value)

The distinction has teeth at call sites. With the bound save, passing a Model subclass returns that same subclass — the solver keeps T at its narrowest. With the constrained width, passing a bool (which is a subtype of int) solves T to int, not bool, so the return type is int; constraints never preserve a subtype. A constraint set must list at least two types — a single-element [T: (int,)] is rejected, since “exactly one of {int}” is just the plain type int and buys nothing.

The legacy equivalents — TypeVar("T", bound=Model) and TypeVar("T", int, str) — mean the same thing; PEP 695 only moves where you write them. You cannot mix the spellings: passing bound= or positional constraints to an inline [T] is a SyntaxError at parse time, because the brackets are grammar, not a call to TypeVar(). A bound may itself be generic or a union — [T: list[int]] and [T: (int | None)] are both valid — and the bound expression is evaluated lazily, so it can reference a class defined later in the module. Violations are reported as [type-var] by mypy and reportArgumentType by pyright:

# Python 3.12+, mypy 1.x
save(42)          # mypy: [type-var] "Value of type variable T of save cannot be int"
width(3.0)        # mypy: [type-var] — float is neither int nor str

For parameter-level defaults that combine with bounds, see TypeVar defaults (PEP 696).

The lazy-evaluated type statement

The type X = ... statement is not a plain assignment. It binds X to a TypeAliasType object whose right-hand side is stored unevaluated and computed only the first time you read X.__value__. Between definition and that first access the RHS is never executed, which is precisely what lets an alias name something defined later in the file, or reference itself, without string quoting.

Lazy evaluation timeline of a type statement At definition time only a TypeAliasType is created; the right-hand side is evaluated on the first access of dunder value and then cached. type Tree[T] = … RHS stored, not run definition first .__value__ RHS evaluated now first access later reads cached result reused
The right-hand side runs once, on first __value__ access, then the result is cached.
# Python 3.12+, mypy 1.x
type Tree[T] = T | list[Tree[T]]     # recursive alias — RHS not evaluated eagerly

# Compare the legacy form, which evaluates eagerly and needs typing.TypeAlias:
from typing import TypeAlias
Vector: TypeAlias = list[float]      # plain alias, evaluated at definition time

The object you get back is worth inspecting. type Pair[T] = tuple[T, T] produces a typing.TypeAliasType whose __name__ is "Pair", whose __type_params__ is (~T,), and whose __value__ triggers evaluation on read. Subscripting it — Pair[int] — yields a normal parameterized alias that mypy and pyright expand back to tuple[int, int]. Because the alias is a real runtime object rather than a bare annotation, isinstance(Pair, TypeAliasType) is True, which is how introspection libraries detect the new form.

# Python 3.12+
from typing import TypeAliasType
type Pair[T] = tuple[T, T]
Pair.__value__                       # tuple[T, T]  (evaluated on this access)
isinstance(Pair, TypeAliasType)      # True

There is no from __future__ opt-in for the type statement — it is a 3.12 grammar addition. On 3.11 and earlier you fall back to TypeAlias from typing (or typing_extensions.TypeAliasType, which backports the lazy object but still requires the assignment spelling rather than the type keyword). Because evaluation is deferred, type Tree[T] = ... is a much closer fit for self-referential and mutually recursive type aliases than the old assignment form, which would raise NameError on a forward reference unless every name were quoted.

Inferred variance and lexical scoping

Under PEP 695 you no longer pass covariant=True / contravariant=True — those keywords are not even grammatical inside [...]. Instead the checker examines every position in which the parameter appears across the class body and infers its variance from a simple rule: a parameter used only in output positions (return types, read-only properties) is covariant, one used only in input positions (method parameters, writable attributes) is contravariant, and one used in both is invariant.

Usage position maps to inferred variance Output-only usage maps to covariant, input-only maps to contravariant, and mixed usage maps to invariant. output only ( -> T ) input only ( item: T ) both input & output covariant contravariant invariant
The checker reads each parameter's positions and picks the only sound variance.
# Python 3.12+, mypy 1.x / pyright
class Producer[T]:
    def get(self) -> T: ...          # T only in output → inferred covariant

class Sink[T]:
    def put(self, item: T) -> None: ...  # T only in input → inferred contravariant

class Box[T]:
    def get(self) -> T: ...          # output …
    def set(self, item: T) -> None: ...  # … and input → inferred invariant

Inference means the annotation on a subtype relationship is computed, not asserted. Producer[int] is accepted where Producer[object] is expected (covariance flows the subtype the same direction as int <: object); Sink[object] is accepted where Sink[int] is expected (contravariance reverses it); Box[int] and Box[object] are unrelated. Pyright will even surface the decision — hovering the class shows class Box[T@Box] and its inferred variance — and if you introduce a genuine conflict across a base and its subclass, pyright reports reportGeneralTypeIssues while mypy reports [misc].

The parameter is also lexically scoped, and this is a real behavioral change from the legacy form. The T in class Repo[T] is visible only inside Repo’s body; a method that declares its own [T] shadows the class parameter with an independent variable, and a T written at module scope is a third, unrelated object. Referencing a parameter outside its scope is [name-defined] under mypy.

# Python 3.12+, mypy 1.x
class Repo[T]:
    def convert[T](self, x: T) -> T:  # method's [T] shadows the class T — a distinct variable
        return x

def loose(x: T) -> T:                 # mypy: [name-defined] — no module-level T in scope
    return x

Under the legacy form a single module-level TypeVar object could be shared — deliberately or by accident — across unrelated classes; scoping removes that entire failure mode, at the cost of no longer being able to reuse one named parameter across several definitions (see the pitfalls below).

Analyzer behaviour

Both checkers implement PEP 695, but they gate it on the configured target version differently and report the same misuse under different diagnostic codes. The table below pairs the common failures with what each tool emits, so a shared CI config can be read against both.

mypy and pyright diagnostics for PEP 695 A grid mapping four PEP 695 problems to the diagnostic code mypy emits and the code pyright emits. problem mypy pyright bound violation [type-var] reportArgumentType out-of-scope parameter [name-defined] reportUndefinedVariable bad ParamSpec/variance use [misc] reportInvalidTypeVarUse redundant Generic base [misc] reportGeneralTypeIssues
Same misuse, two vocabularies — align a CI config against both columns.

mypy

PEP 695 syntax requires mypy 1.x and a target of Python 3.12. Run it with --python-version 3.12 (or set it in config); on an older target mypy reports the brackets as a syntax error, since the runtime grammar itself only parses them on 3.12. Misusing an inferred-variance parameter — e.g. adding a mutation that makes a covariant parameter unsound in a subtype — surfaces as [misc], referencing a parameter outside its lexical scope raises [name-defined], and bound or constraint violations report [type-var]. mypy gained full PEP 695 support in 1.11 (earlier 1.x releases parsed the syntax only behind the --enable-incomplete-feature=NewGenericSyntax flag), so pin a recent version in CI.

# Python 3.12+, mypy 1.x
def save[T: Model](row: T) -> T: ...
save(42)                             # mypy error: [type-var]
# "Value of type variable T of save cannot be int"

Pin the target in config so CI and local runs agree:

# pyproject.toml — required for PEP 695 under mypy
[tool.mypy]
python_version = "3.12"

pyright

pyright supports PEP 695 natively with no flag and applies the same variance inference. A bound or constraint violation is reportArgumentType; putting a ParamSpec where an ordinary type belongs, or otherwise misusing a parameter, is reportInvalidTypeVarUse; an inferred-variance conflict in a class hierarchy is reportGeneralTypeIssues; and a reference to an out-of-scope parameter is reportUndefinedVariable. Unlike mypy, pyright will parse and check 3.12 syntax even when pythonVersion is set lower, but it then adds a reportGeneralTypeIssues note that the construct is unavailable on the target runtime — so keeping pythonVersion accurate is what makes the diagnostics match what will actually run.

// pyrightconfig.json — match the runtime so unavailable-syntax notes are correct
{
  "pythonVersion": "3.12",
  "typeCheckingMode": "strict"
}

The two checkers agree on every common case; where they diverge on more exotic generics — variance of higher-kinded aliases, TypeVarTuple expansion order — the differences are catalogued in pyright vs mypy. Running both in CI is the reliable way to catch a PEP 695 construct that one tool accepts and the other rejects.

Strictness tuning

While migrating a package incrementally, you rarely want to flip the whole codebase to PEP 695 at once. The workable pattern is a strict global base — python_version = "3.12" plus strict — that applies everywhere, with narrow per-module overrides that peel back exactly one error code for the modules still mid-migration. The override layer sits on top of the base and never widens it beyond the one module and one code you name, so new code keeps the full strictness while a legacy module is exempted only from the specific diagnostic it cannot yet satisfy.

Global strict base with a scoped module override A wide base layer sets python version 3.12 and strict for every module; a narrower override layer on top disables one error code for a single module. [tool.mypy] python_version = "3.12", strict global base — applies to every module [[tool.mypy.overrides]] disable_error_code = ["type-var"] one module only
The strict base stays in force everywhere; an override lifts exactly one code for one module.
# pyproject.toml — relax one in-progress module
[[tool.mypy.overrides]]
module = "services.legacy_repo"
disable_error_code = ["type-var"]

The pyright equivalent is a per-file # pyright: reportArgumentType=false comment at the top of the module, or an overrides entry in pyrightconfig.json keyed on a file glob. Prefer a targeted disable_error_code over a blanket # type: ignore: the narrow form still surfaces every unrelated error in the module, so a real regression in services.legacy_repo is not masked while you tolerate its outstanding [type-var] cases. Treat these overrides as debt — record the count somewhere visible and drive it to zero as each module’s runtime floor reaches 3.12, because an override that outlives its migration silently hides genuine bound violations. For a project-wide policy see mypy configuration & strictness.

Debugging false positives

Most “false positives” around PEP 695 are the checker correctly reacting to a change you did not realize was significant. The archetypal case: an inferred-covariant parameter “suddenly” rejects a setter you add later. That is not spurious — adding def put(self, item: T) moves T into an input position, so the checker re-infers the variance and the parameter can no longer be covariant. mypy reports [misc] and pyright reportGeneralTypeIssues at the point the covariance is relied upon.

Adding a setter transitions T from covariant to invariant The parameter starts covariant with only a getter; a transition labelled add a setter moves it to an invariant state where covariant uses now error. covariant only def get(self) -> T add def put(self, item: T) T now in an input position invariant covariant use now errors
A mutating method flips the inferred variance — the resulting error is real, not spurious.

The fix is to accept the invariance (the class is now genuinely read-write) rather than to suppress the code. If you truly need a covariant read view, split responsibilities: expose a read-only Protocol with only the getter for callers that must be covariant, and keep the mutable class separate. Two other apparent false positives have the same “working as intended” character. A [name-defined] (mypy) or reportUndefinedVariable (pyright) on a parameter is almost always a scoping error — you referenced a method’s [T] from a sibling method, or a bare T at module scope; hoist the parameter to the class header if it genuinely must be shared. And if pyright flags a construct as unavailable while mypy stays silent, your two target-version settings have drifted apart — reconcile pythonVersion and python_version before reaching for any # type: ignore.

Common pitfalls

The mistakes below are the ones that most often trip up teams adopting PEP 695. Each has a mechanical cause and a one-line fix, so they are quick to rule out the moment a checker complains — usually the error message names the exact bracket or base at fault.

Four common PEP 695 pitfalls A vertical list marks four pitfalls: forgetting the 3.12 target, keeping the Generic base, passing bound or covariant inline, and assuming module-level reuse. × no python_version = "3.12" → brackets are a SyntaxError × class Repo[T](Generic[T]) → redundant base, rejected × bound= / covariant= inline → syntax error, use the colon × reusing one [T] across defs → each [T] is a distinct variable
Four inline-parameter pitfalls and their one-line causes.
  • Forgetting the target version: PEP 695 brackets are a syntax error under mypy unless python_version = "3.12". There is no from __future__ backport — see the migration guide.
  • Keeping the Generic[T] base: class Repo[T](Generic[T]) is redundant and rejected; the bracket syntax already makes the class generic. mypy reports [misc] and pyright reportGeneralTypeIssues for the doubled declaration.
  • Passing bound=/covariant= inline: Those keywords belong to TypeVar(). With [T: Model] the bound is the colon; the keyword form is a syntax error, because the brackets are grammar rather than a call.
  • Assuming module-level reuse: An inline [T] is local. To share one parameter across several functions you still declare a TypeVar — or repeat [T], which creates independent variables.
  • Migrating a mixed-runtime module: if any deployment target is still 3.11, the file fails to import there with SyntaxError. Convert leaf modules first and keep shared modules on legacy TypeVar until every runtime is 3.12.

FAQ

Do I still need to import TypeVar? Only for legacy declarations or when you deliberately want a named, module-level, reusable type variable. Pure PEP 695 code needs no typing import for its parameters.

Does PEP 695 change runtime behaviour? The brackets create real TypeVar objects (visible via __type_params__) and a lazy TypeAliasType, but they impose no runtime type checking — annotations stay advisory.

Can I use [T] on Python 3.11? No. The syntax is parsed only by the 3.12+ compiler. Stay on TypeVar until every runtime is 3.12.

Back to Advanced Typing Patterns & Generics