Back to Ruff

Generic type aliases: PEP 695 syntax

crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md

0.15.1219.3 KB
Original Source

Generic type aliases: PEP 695 syntax

toml
[environment]
python-version = "3.13"

Defining a generic alias

At its simplest, to define a type alias using PEP 695 syntax, you add a list of TypeVars, ParamSpecs or TypeVarTuples after the alias name.

py
from typing import Callable
from ty_extensions import generic_context

type SingleTypevar[T] = list[T]
type MultipleTypevars[T, S] = tuple[T, S]
type SingleParamSpec[**P] = Callable[P, int]
type TypeVarAndParamSpec[T, **P] = Callable[P, T]
type SingleTypeVarTuple[*Ts] = tuple[*Ts]
type TypeVarAndTypeVarTuple[T, *Ts] = tuple[T, *Ts]

# revealed: ty_extensions.GenericContext[T@SingleTypevar]
reveal_type(generic_context(SingleTypevar))
# revealed: ty_extensions.GenericContext[T@MultipleTypevars, S@MultipleTypevars]
reveal_type(generic_context(MultipleTypevars))

# TODO: support `TypeVarTuple` properly
# (these should include the `TypeVarTuple`s in their generic contexts)
# revealed: ty_extensions.GenericContext[P@SingleParamSpec]
reveal_type(generic_context(SingleParamSpec))
# revealed: ty_extensions.GenericContext[T@TypeVarAndParamSpec, P@TypeVarAndParamSpec]
reveal_type(generic_context(TypeVarAndParamSpec))
# revealed: ty_extensions.GenericContext[]
reveal_type(generic_context(SingleTypeVarTuple))
# revealed: ty_extensions.GenericContext[T@TypeVarAndTypeVarTuple]
reveal_type(generic_context(TypeVarAndTypeVarTuple))

You cannot use the same typevar more than once.

py
# error: [invalid-syntax] "duplicate type parameter"
type RepeatedTypevar[T, T] = tuple[T, T]

Legacy type variables cannot be used:

py
from typing import TypeVar

V = TypeVar("V")

# error: [unbound-type-variable]
type TA1[K] = dict[K, V]

Specializing type aliases explicitly

The type parameter can be specified explicitly:

py
from typing import Literal

type C[T] = T

def _(a: C[int], b: C[Literal[5]]):
    reveal_type(a)  # revealed: int
    reveal_type(b)  # revealed: Literal[5]

The specialization must match the generic types:

py
# error: [invalid-type-arguments] "Too many type arguments: expected 1, got 2"
reveal_type(C[int, int])  # revealed: <type alias 'C[Unknown]'>

And non-generic types cannot be specialized:

py
from typing import TypeVar, Protocol, TypedDict

type B = int

# error: [not-subscriptable] "Cannot subscript non-generic type alias `B`"
reveal_type(B[int])  # revealed: Unknown

# error: [not-subscriptable] "Cannot specialize non-generic type alias `B`"
def _(b: B[int]):
    reveal_type(b)  # revealed: Unknown

type IntOrStr = int | str

# error: [not-subscriptable] "Cannot specialize non-generic type alias `IntOrStr`"
def _(c: IntOrStr[int]):
    reveal_type(c)  # revealed: Unknown

type ListOfInts = list[int]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `ListOfInts`"
def _(l: ListOfInts[int]):
    reveal_type(l)  # revealed: Unknown

type List[T] = list[T]

# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in parameter annotations"
def _(l: List[int][int]):
    reveal_type(l)  # revealed: Unknown

# error: [invalid-type-form] "Only simple names and dotted names can be subscripted in type alias values"
type DoubleSpecialization[T] = list[T][T]

def _(d: DoubleSpecialization[int]):
    reveal_type(d)  # revealed: Unknown

type Tuple = tuple[int, str]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `Tuple`"
def _(doubly_specialized: Tuple[int]):
    reveal_type(doubly_specialized)  # revealed: Unknown

T = TypeVar("T")

class LegacyProto(Protocol[T]):
    pass

type LegacyProtoInt = LegacyProto[int]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `LegacyProtoInt`"
def _(x: LegacyProtoInt[int]):
    reveal_type(x)  # revealed: Unknown

class Proto[T](Protocol):
    pass

type ProtoInt = Proto[int]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `ProtoInt`"
def _(x: ProtoInt[int]):
    reveal_type(x)  # revealed: Unknown

# TODO: TypedDict is just a function object at runtime, we should emit an error
class LegacyDict(TypedDict[T]):
    # error: [unbound-type-variable]
    x: T

type LegacyDictInt = LegacyDict[int]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `LegacyDictInt`"
def _(x: LegacyDictInt[int]):
    reveal_type(x)  # revealed: Unknown

class Dict[T](TypedDict):
    x: T

type DictInt = Dict[int]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `DictInt`"
def _(x: DictInt[int]):
    reveal_type(x)  # revealed: Unknown

type Union = list[str] | list[int]

# error: [not-subscriptable] "Cannot specialize non-generic type alias `Union`"
def _(x: Union[int]):
    reveal_type(x)  # revealed: Unknown

If the type variable has an upper bound, the specialized type must satisfy that bound:

py
type Bounded[T: int] = list[T]
type BoundedByUnion[T: int | str] = list[T]

class IntSubclass(int): ...

reveal_type(Bounded[int])  # revealed: <type alias 'Bounded[int]'>
reveal_type(Bounded[IntSubclass])  # revealed: <type alias 'Bounded[IntSubclass]'>

# error: [invalid-type-arguments] "Type `str` is not assignable to upper bound `int` of type variable `T@Bounded`"
reveal_type(Bounded[str])  # revealed: <type alias 'Bounded[Unknown]'>

# error: [invalid-type-arguments] "Type `int | str` is not assignable to upper bound `int` of type variable `T@Bounded`"
reveal_type(Bounded[int | str])  # revealed: <type alias 'Bounded[Unknown]'>

reveal_type(BoundedByUnion[int])  # revealed: <type alias 'BoundedByUnion[int]'>
reveal_type(BoundedByUnion[IntSubclass])  # revealed: <type alias 'BoundedByUnion[IntSubclass]'>
reveal_type(BoundedByUnion[str])  # revealed: <type alias 'BoundedByUnion[str]'>
reveal_type(BoundedByUnion[int | str])  # revealed: <type alias 'BoundedByUnion[int | str]'>

type TupleOfIntAndStr[T: int, U: str] = tuple[T, U]

def _(x: TupleOfIntAndStr[int, str]):
    reveal_type(x)  # revealed: tuple[int, str]

# error: [invalid-type-arguments] "Type `int` is not assignable to upper bound `str` of type variable `U@TupleOfIntAndStr`"
def _(x: TupleOfIntAndStr[int, int]):
    reveal_type(x)  # revealed: tuple[int, Unknown]

If the type variable is constrained, the specialized type must satisfy those constraints:

py
type Constrained[T: (int, str)] = list[T]

reveal_type(Constrained[int])  # revealed: <type alias 'Constrained[int]'>

# TODO: error: [invalid-argument-type]
# TODO: revealed: Constrained[Unknown]
reveal_type(Constrained[IntSubclass])  # revealed: <type alias 'Constrained[IntSubclass]'>

reveal_type(Constrained[str])  # revealed: <type alias 'Constrained[str]'>

# TODO: error: [invalid-argument-type]
# TODO: revealed: Unknown
reveal_type(Constrained[int | str])  # revealed: <type alias 'Constrained[int | str]'>

# error: [invalid-type-arguments] "Type `object` does not satisfy constraints `int`, `str` of type variable `T@Constrained`"
reveal_type(Constrained[object])  # revealed: <type alias 'Constrained[Unknown]'>

type TupleOfIntOrStr[T: (int, str), U: (int, str)] = tuple[T, U]

def _(x: TupleOfIntOrStr[int, str]):
    reveal_type(x)  # revealed: tuple[int, str]

# error: [invalid-type-arguments] "Type `object` does not satisfy constraints `int`, `str` of type variable `U@TupleOfIntOrStr`"
def _(x: TupleOfIntOrStr[int, object]):
    reveal_type(x)  # revealed: tuple[int, Unknown]

If the type variable has a default, it can be omitted:

py
type WithDefault[T, U = int] = dict[T, U]

reveal_type(WithDefault[str, str])  # revealed: <type alias 'WithDefault[str, str]'>
reveal_type(WithDefault[str])  # revealed: <type alias 'WithDefault[str, int]'>

If the type alias is not specialized explicitly, it is implicitly specialized to Unknown:

py
type G[T] = list[T]

def _(g: G):
    reveal_type(g)  # revealed: list[Unknown]

Unless a type default was provided:

py
type G[T = int] = list[T]

def _(g: G):
    reveal_type(g)  # revealed: list[int]

Self-referential defaults should not crash type inference:

py
# error: [cyclic-type-alias-definition] "Cyclic definition of `A`"
type A[T = A] = A[int]

A self-referential default that does not reference itself in the alias body should also not crash, even when the default is evaluated (e.g., by omitting the type argument):

py
type B[T = B] = list[T]

def _(x: B) -> None:
    pass

Mutually-referential defaults (where two type aliases reference each other via their typevar defaults) should also not crash:

py
type X[T = Y] = list[T]
type Y[U = X] = list[U]

def _(x: X, y: Y) -> None:
    pass

Indirect self-references through a chain of type aliases should also not crash:

py
type P[T = R] = list[T]
type Q[T = P] = list[T]
type R[T = Q] = list[T]

def _(p: P) -> None:
    pass

Snapshots of verbose diagnostics

py
class A: ...
class B[T]: ...

type AliasA = A
type AliasB = B[int]

# snapshot: not-subscriptable
def _(a: AliasA[int]): ...
snapshot
error[not-subscriptable]: Cannot specialize non-generic type alias `AliasA`
 --> src/mdtest_snippet.py:8:10
  |
8 | def _(a: AliasA[int]): ...
  |          ------^^^^^
  |          |
  |          Alias to `A`, which is not generic
  |
py
# snapshot: not-subscriptable
def _(b: AliasB[int]): ...
snapshot
error[not-subscriptable]: Cannot specialize non-generic type alias `AliasB`
  --> src/mdtest_snippet.py:10:10
   |
10 | def _(b: AliasB[int]): ...
   |          ------^^^^^
   |          |
   |          Alias to `B[int]`, which is already specialized
   |

Aliases are not callable

py
type A = int
type B[T] = T

# error: [call-non-callable] "Object of type `TypeAliasType` is not callable"
reveal_type(A())  # revealed: Unknown

# error: [call-non-callable] "Object of type `GenericAlias` is not callable"
reveal_type(B[int]())  # revealed: Unknown

Recursive Truthiness

Make sure we handle cycles correctly when computing the truthiness of a generic type alias:

py
type X[T: X] = T

def _(x: X):
    assert x

Recursive generic type aliases

py
type RecursiveList[T] = T | list[RecursiveList[T]]

r1: RecursiveList[int] = 1
r2: RecursiveList[int] = [1, [1, 2, 3]]
# error: [invalid-assignment] "Object of type `Literal["a"]` is not assignable to `RecursiveList[int]`"
r3: RecursiveList[int] = "a"
# error: [invalid-assignment]
r4: RecursiveList[int] = ["a"]
# TODO: this should be an error
r5: RecursiveList[int] = [1, ["a"]]

def _(x: RecursiveList[int]):
    if isinstance(x, list):
        # TODO: should be `list[RecursiveList[int]]
        reveal_type(x[0])  # revealed: int | list[Any]
    if isinstance(x, list) and isinstance(x[0], list):
        # TODO: should be `list[RecursiveList[int]]`
        reveal_type(x[0])  # revealed: list[Any]

Assignment checks respect structural subtyping, i.e. type aliases with the same structure are assignable to each other.

py
# This is structurally equivalent to RecursiveList[T].
type RecursiveList2[T] = T | list[T | list[RecursiveList[T]]]
# This is not structurally equivalent to RecursiveList[T].
type RecursiveList3[T] = T | list[list[RecursiveList[T]]]

def _(x: RecursiveList[int], y: RecursiveList2[int]):
    r1: RecursiveList2[int] = x
    # error: [invalid-assignment]
    r2: RecursiveList3[int] = x

    r3: RecursiveList[int] = y
    # error: [invalid-assignment]
    r4: RecursiveList3[int] = y

It is also possible to handle divergent type aliases that are not actually have instances.

py
# The type variable `T` has no meaning here, it's just to make sure it works correctly.
type DivergentList[T] = list[DivergentList[T]]

d1: DivergentList[int] = []
# error: [invalid-assignment]
d2: DivergentList[int] = [1]
# error: [invalid-assignment]
d3: DivergentList[int] = ["a"]
# TODO: this should be an error
d4: DivergentList[int] = [[1]]

def _(x: DivergentList[int]):
    d1: DivergentList[int] = [x]
    d2: DivergentList[int] = x[0]

Solving generics with type alias parameters

A generic function parameter annotated with a PEP 695 type alias that contains a type variable should properly infer the specialization from the argument:

py
type MyList[T] = list[T]
type MyDict[K, V] = dict[K, V]

def head[T](my_list: MyList[T]) -> T:
    return my_list[0]

def get_value[K, V](my_dict: MyDict[K, V], key: K) -> V:
    return my_dict[key]

reveal_type(head([1, 2]))  # revealed: int
reveal_type(head(["a", "b"]))  # revealed: str

d: dict[str, int] = {"a": 1}
reveal_type(get_value(d, "a"))  # revealed: int

It also works in the reverse direction, where the type alias is used as the argument type:

py
type MyList[T] = list[T]

def head[T](l: list[T]) -> T:
    return l[0]

def _(x: MyList[int]):
    reveal_type(head(x))  # revealed: int

Bidirectional type inference with union type aliases

When a PEP 695 type alias expands to a union, bidirectional type inference should still work correctly. The type alias should be expanded to its value type when determining the expected type for specialization inference.

py
type MaybeList[T] = list[T] | T

def test[X: int](items: list[X]) -> list[X]:
    # The annotation MaybeList[str | int] expands to `list[str | int] | str | int`.
    # Bidirectional inference should infer list[str | int] from the list() call.
    a: MaybeList[str | int] = list(items)
    # The revealed type is list[str | int] because that's what list() returns.
    reveal_type(a)  # revealed: list[str | int]
    return items

This also works for more complex cases with multiple generic functions:

py
type OptionalList[T] = list[T] | None

def copy_list[T](items: list[T]) -> list[T]:
    return list(items)

def _(values: list[int]) -> OptionalList[int]:
    result: OptionalList[int] = copy_list(values)
    # The revealed type is list[int] because that's what copy_list returns.
    reveal_type(result)  # revealed: list[int]
    return result

Union type aliases also work correctly with TypedDict dict literal inference:

py
from typing import TypedDict

class Person(TypedDict):
    name: str
    age: int

type MaybePerson = Person | None

def _(p: MaybePerson):
    # Dict literal should be inferred as Person, not dict[str, str | int]
    x: MaybePerson = {"name": "Alice", "age": 30}
    reveal_type(x)  # revealed: Person

And with dict() calls in TypedDict context:

py
from typing import TypedDict

class Dog(TypedDict):
    name: str
    breed: str

type MaybeDog = Dog | None

def _():
    # dict() call with keyword args should be inferred as Dog
    animal: MaybeDog = dict(name="Buddy", breed="Labrador")
    reveal_type(animal)  # revealed: Dog

And with set literal inference:

py
type MaybeSet[T] = set[T] | T

def _():
    # Set literal should be inferred as set[int]
    x: MaybeSet[int] = {1, 2, 3}
    reveal_type(x)  # revealed: set[int]

Fully qualified type alias names in error messages

When two type aliases have the same name but are in different scopes, they should be fully qualified in error messages to distinguish them:

py
class A:
    class B[T]:
        pass

    type D = list[int]

class C:
    class B[T]:
        pass

    type D = list[str]

def f(b: C.B[C.D]) -> None:
    # error: [invalid-assignment] "Object of type `mdtest_snippet.C.B[mdtest_snippet.C.D]` is not assignable to `mdtest_snippet.A.B[mdtest_snippet.A.D]`"
    a: A.B[A.D] = b

Fully qualified type alias names in nested classes

Type aliases in nested classes should include the full class path:

py
class Outer1:
    class Inner:
        type Alias = int

class Outer2:
    class Inner:
        type Alias = str

def g(x: Outer1.Inner.Alias, y: Outer2.Inner.Alias) -> None:
    # error: [invalid-assignment] "Object of type `mdtest_snippet.Outer2.Inner.Alias` is not assignable to `mdtest_snippet.Outer1.Inner.Alias`"
    a: Outer1.Inner.Alias = y

Fully qualified generic type aliases

Ambiguous generic type aliases should also be fully qualified:

py
class X:
    type GenAlias[T] = list[T]

class Y:
    type GenAlias[T] = dict[str, T]

def h(x: X.GenAlias[int], y: Y.GenAlias[int]) -> None:
    # error: [invalid-assignment] "Object of type `mdtest_snippet.Y.GenAlias[int]` is not assignable to `mdtest_snippet.X.GenAlias[int]`"
    a: X.GenAlias[int] = y

Non-ambiguous type aliases should not be qualified

Type aliases with unique names should NOT be qualified:

py
class P:
    type UniqueAlias1 = int

class Q:
    type UniqueAlias2 = str

def i(x: P.UniqueAlias1, y: Q.UniqueAlias2) -> None:
    # error: [invalid-assignment] "Object of type `UniqueAlias2` is not assignable to `UniqueAlias1`"
    a: P.UniqueAlias1 = y

Class and type alias with same name

When a class and a type alias have the same name in different scopes, both should be fully qualified to distinguish them in error messages:

py
class Container1:
    class Item:
        pass

class Container2:
    type Item = str

def j(x: Container1.Item, y: Container2.Item) -> None:
    # error: [invalid-assignment] "Object of type `mdtest_snippet.Container2.Item` is not assignable to `mdtest_snippet.Container1.Item`"
    a: Container1.Item = y

Default type parameter after TypeVarTuple

A type parameter with a default cannot follow a TypeVarTuple in a type parameter list. This is prohibited by the typing spec because a TypeVarTuple consumes all remaining positional type arguments, making any subsequent defaults meaningless.

py
# snapshot: invalid-type-variable-default
type Alias1[*Ts, T = int] = tuple[*Ts, T]
snapshot
error[invalid-type-variable-default]: Type parameters with defaults cannot follow a TypeVarTuple parameter
 --> src/mdtest_snippet.py:2:13
  |
2 | type Alias1[*Ts, T = int] = tuple[*Ts, T]
  |             ---  ^^^^^^^ `T` has a default
  |             |
  |             `Ts` is a TypeVarTuple
  |
info: See https://typing.python.org/en/latest/spec/generics.html#defaults-following-typevartuple
py
# snapshot: invalid-type-variable-default
type Alias2[T1, *Ts, T2 = int] = tuple[T1, *Ts, T2]
snapshot
error[invalid-type-variable-default]: Type parameters with defaults cannot follow a TypeVarTuple parameter
 --> src/mdtest_snippet.py:4:17
  |
4 | type Alias2[T1, *Ts, T2 = int] = tuple[T1, *Ts, T2]
  |                 ---  ^^^^^^^^ `T2` has a default
  |                 |
  |                 `Ts` is a TypeVarTuple
  |
info: See https://typing.python.org/en/latest/spec/generics.html#defaults-following-typevartuple
py
# snapshot: invalid-type-variable-default
type Alias3[*Ts, T1 = int, T2 = str] = tuple[*Ts, T1, T2]
snapshot
error[invalid-type-variable-default]: Type parameters with defaults cannot follow a TypeVarTuple parameter
 --> src/mdtest_snippet.py:6:13
  |
6 | type Alias3[*Ts, T1 = int, T2 = str] = tuple[*Ts, T1, T2]
  |             ---  ^^^^^^^^  -------- `T2` also has a default
  |             |    |
  |             |    `T1` has a default
  |             `Ts` is a TypeVarTuple
  |
info: See https://typing.python.org/en/latest/spec/generics.html#defaults-following-typevartuple
py
# snapshot: invalid-type-variable-default
type Alias4[*Us, *Ts = *tuple[int, str]] = tuple[*Us, *Ts]
snapshot
error[invalid-type-variable-default]: Type parameters with defaults cannot follow a TypeVarTuple parameter
 --> src/mdtest_snippet.py:8:13
  |
8 | type Alias4[*Us, *Ts = *tuple[int, str]] = tuple[*Us, *Ts]
  |             ---  ^^^^^^^^^^^^^^^^^^^^^^ `Ts` has a default
  |             |
  |             `Us` is a TypeVarTuple
  |
info: See https://typing.python.org/en/latest/spec/generics.html#defaults-following-typevartuple
py
# These are fine:
type Ok1[T, *Ts] = tuple[T, *Ts]