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.
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.
# 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.
# 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.
# 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.
# 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
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.
# 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.
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.
- Forgetting the target version: PEP 695 brackets are a syntax error under mypy unless
python_version = "3.12". There is nofrom __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 pyrightreportGeneralTypeIssuesfor the doubled declaration. - Passing
bound=/covariant=inline: Those keywords belong toTypeVar(). 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 aTypeVar— 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 legacyTypeVaruntil 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.