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. 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 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.
  • Passing bound=/covariant= inline: Those keywords belong to TypeVar(). 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 a TypeVar — 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.

Back to Advanced Typing Patterns & Generics