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.
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. No import, no
Generic base, no 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, accessible via
Repo.__type_params__, but you never name it at module scope.
Bounds and constraints
A bound restricts the parameter to subtypes of one type; constraints restrict it to exactly one of a fixed set. Both use colon syntax inside the brackets.
# 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 legacy equivalents — TypeVar("T", bound=Model) and TypeVar("T", int, str) — mean the same
thing; PEP 695 only changes where you write them. Mixing the forms (passing bound= to an inline
parameter) is a syntax error.
The lazy-evaluated type statement
The type X = ... statement is not a plain assignment. Its value is a TypeAliasType whose
right-hand side is evaluated lazily, the first time .__value__ is accessed. That is what lets
an alias refer to a name defined later in the module — forward references work without 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
Because evaluation is deferred, type Tree[T] = ... is a much closer fit for self-referential
type aliases than the old assignment form.
Inferred variance and lexical scoping
Under PEP 695 you no longer pass covariant=True / contravariant=True. The checker reads how each
parameter is used and infers its variance:
output-only positions become covariant, input-only become contravariant, both become 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
The parameter is also lexically scoped: the T in class Repo[T] is visible only inside Repo’s
body and is a distinct variable from a T introduced by a method’s own [T]. This eliminates a
class of bugs where a single module-level TypeVar was accidentally shared across unrelated classes.
Analyzer behaviour
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. Misusing an inferred-variance parameter — e.g. declaring it where the inferred variance
is unsound — surfaces as [misc], and referencing a parameter outside its lexical scope raises
[name-defined]. Bound violations report [type-var].
# 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 inference. A bound violation is
reportArgumentType; using covariant=/contravariant= on an inline parameter, or otherwise
misusing the parameter, is reportInvalidTypeVarUse; an inferred-variance conflict in a class
hierarchy is reportGeneralTypeIssues. pyright will check 3.12 syntax even when pythonVersion is
lower, but flags the construct as unavailable on the target — keep pythonVersion accurate so the
diagnostics match your runtime.
Strictness tuning
While migrating a package incrementally, you can scope PEP 695 checking per module rather than flipping the whole codebase. Keep the global target at 3.12 but relax error codes on modules still mid-migration:
# pyproject.toml — relax one in-progress module
[[tool.mypy.overrides]]
module = "services.legacy_repo"
disable_error_code = ["type-var"]
Debugging false positives
A common surprise: an inferred-covariant parameter “suddenly” rejects a setter you add later. That is
not a false positive — adding def put(self, item: T) moves T into an input position, so it can no
longer be covariant, and mypy reports [misc]. The fix is to accept the invariance (the class is now
genuinely read-write) rather than to suppress the code.
Common pitfalls
- 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. - Passing
bound=/covariant=inline: Those keywords belong toTypeVar(). With[T: Model]the bound is the colon; the keyword form is a syntax error. - 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.
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.