Back to Ruff

PEP 695 Generics

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

0.15.1235.6 KB
Original Source

PEP 695 Generics

toml
[environment]
python-version = "3.13"

PEP 695 and Python 3.12 introduced new, more ergonomic syntax for type variables.

Type variables

Defining PEP 695 type variables

PEP 695 introduces a new syntax for defining type variables. The resulting type variables are instances of typing.TypeVar, just like legacy type variables.

py
def f[T]():
    reveal_type(type(T))  # revealed: <class 'TypeVar'>
    reveal_type(T)  # revealed: TypeVar
    reveal_type(T.__name__)  # revealed: Literal["T"]

Type variables with a default

Note that the __default__ property is only available in Python ≥3.13.

toml
[environment]
python-version = "3.13"
py
def f[T = int]():
    reveal_type(type(T))  # revealed: <class 'TypeVar'>
    reveal_type(T)  # revealed: TypeVar
    reveal_type(T.__default__)  # revealed: int
    reveal_type(T.__bound__)  # revealed: None
    reveal_type(T.__constraints__)  # revealed: tuple[()]

def g[S]():
    reveal_type(S.__default__)  # revealed: NoDefault

Using other typevars as a default

toml
[environment]
python-version = "3.13"
py
class Valid[T, U = T, V = T | U]: ...

reveal_type(Valid())  # revealed: Valid[Unknown, Unknown, Unknown]
reveal_type(Valid[int]())  # revealed: Valid[int, int, int]
reveal_type(Valid[int, str]())  # revealed: Valid[int, str, int | str]
reveal_type(Valid[int, str, None]())  # revealed: Valid[int, str, None]

# error: [unresolved-reference]
class Invalid[S = T]: ...

Invalid defaults

A TypeVar default must be compatible with its bound or constraints.

Concrete default with a bound

The default must be assignable to the bound:

py
# error: [invalid-type-variable-default] "TypeVar default is not assignable to the TypeVar's upper bound"
def f[T: str = int](): ...
def g[T: float = int](): ...

Concrete default with constraints

The default must be one of the constrained types, even if it is a subtype of one of them:

py
from typing import Any

# error: [invalid-type-variable-default] "TypeVar default is inconsistent with the TypeVar's constraints: `bytes` is not one of the constraints of `T`"
def f[T: (int, str) = bytes](): ...
def g[T: (int, str) = int](): ...

# A subtype is not sufficient; the default must be exactly one of the constraints.
# error: [invalid-type-variable-default] "TypeVar default is inconsistent with the TypeVar's constraints: `bool` is not one of the constraints of `T`"
def h[T: (int, str) = bool](): ...

# `Any` is always allowed as a default, even for constrained TypeVars.
def i[T: (int, str) = Any](): ...

Default TypeVar's bound must be assignable to the outer bound

When the default is a TypeVar, its upper bound must be assignable to the outer TypeVar's bound:

py
from typing import TypeVar

T1 = TypeVar("T1", bound=int)
T3 = TypeVar("T3", bound=str)

# OK: `float` in a type expression means `int | float`,
# and the upper bound of `T1` (`int`) is assignable to `int | float`
def f[S: float = T1](): ...

# `T3` has bound `str`, which is not assignable to `int | float`
# error: [invalid-type-variable-default] "Default `T3` of TypeVar `U` is not assignable to upper bound `int | float` of `U` because its upper bound `str` is not assignable to `int | float`"
def g[U: float = T3](): ...

An unbounded default TypeVar has an implicit object bound

An unbounded TypeVar has an implicit upper bound of object, which is not assignable to a more restrictive bound:

py
from typing import TypeVar

T1 = TypeVar("T1")

# error: [invalid-type-variable-default] "Default `T1` of TypeVar `S` is not assignable to upper bound `int` of `S` because its upper bound `object` is not assignable to `int`"
def f[S: int = T1](): ...

A constrained default TypeVar's constraints must all be assignable to the outer bound

When the default TypeVar has constraints, every constraint must be assignable to the outer bound:

py
from typing import TypeVar

T1 = TypeVar("T1", int, str)
T2 = TypeVar("T2", int, bool)

# OK: `T1`'s constraints are `int` and `str`,
# which are both assignable to `object`
def f[S: object = T1](): ...

# `T1` has constraint `str`, which is not assignable to bound `int`
# error: [invalid-type-variable-default] "Default `T1` of TypeVar `U` is not assignable to upper bound `int` of `U` because constraint `str` of `T1` is not assignable to `int`"
def g[U: int = T1](): ...

# OK: `T2`'s constraints are `int` and `bool`,
# which are both assignable to `int`
def h[V: int = T2](): ...

Local type-parameter defaults

PEP 695 type parameters from the same scope can be used as defaults:

py
# OK: `T` has bound `int`, which is assignable to `int`
def f[T: int, U: int = T](): ...

# `T` has bound `int`, which is not assignable to `str`
# error: [invalid-type-variable-default] "Default `T` of TypeVar `U` is not assignable to upper bound `str` of `U` because its upper bound `int` is not assignable to `str`"
def g[T: int, U: str = T](): ...

# OK: `T`'s constraints ({int, str}) are a subset of `U`'s ({int, str, bool})
def h[T: (int, str), U: (int, str, bool) = T](): ...

# `T` has constraint `int` which is not one of `U`'s constraints ({bool, complex})
# error: [invalid-type-variable-default]
def i[T: (int, str), U: (bool, complex) = T](): ...

A constrained default TypeVar's constraints must be a subset of the outer constraints

When the default TypeVar has constraints, they must all appear in the outer TypeVar's constraint list:

py
from typing import TypeVar

T1 = TypeVar("T1", int, str)

# OK: `T1`'s constraints ({int, str}) are a subset
# of `S`'s constraints ({int, str, bool})
def f[S: (int, str, bool) = T1](): ...

# `T1` has constraint `int` which is not one of `U`'s constraints ({bool, complex})
# error: [invalid-type-variable-default]
def g[U: (bool, complex) = T1](): ...

Invalid constraints with default (no cascading diagnostic)

When a TypeVar has fewer than two constraints (already invalid), we should not also emit invalid-type-variable-default:

py
# error: [invalid-type-variable-constraints]
def f[T: (int,) = str](): ...

A non-constrained default TypeVar is incompatible with a constrained outer TypeVar

A bounded or unbounded TypeVar (one without constraints) cannot be used as the default for a constrained TypeVar, because there is no guarantee it will satisfy any of the constraints:

py
from typing import TypeVar

T1 = TypeVar("T1", bound=int)
T2 = TypeVar("T2")

# `T1` has a bound but no constraints
# error: [invalid-type-variable-default]
def f[S: (float, str) = T1](): ...

# `T2` has no bound or constraints
# error: [invalid-type-variable-default]
def g[U: (str, bytes) = T2](): ...

Type variables with an upper bound

py
def f[T: int]():
    reveal_type(type(T))  # revealed: <class 'TypeVar'>
    reveal_type(T)  # revealed: TypeVar
    reveal_type(T.__bound__)  # revealed: int
    reveal_type(T.__constraints__)  # revealed: tuple[()]

def g[S]():
    reveal_type(S.__bound__)  # revealed: None

Type variables with constraints

py
def f[T: (int, str)]():
    reveal_type(type(T))  # revealed: <class 'TypeVar'>
    reveal_type(T)  # revealed: TypeVar
    reveal_type(T.__constraints__)  # revealed: tuple[int, str]
    reveal_type(T.__bound__)  # revealed: None

def g[S]():
    reveal_type(S.__constraints__)  # revealed: tuple[()]

Cannot have only one constraint

TypeVar supports constraining parametric types to a fixed set of possible types...There should be at least two constraints, if any; specifying a single constraint is disallowed.

py
# error: [invalid-type-variable-constraints] "TypeVar must have at least two constrained types"
def f[T: (int,)]():
    pass

No explicit specialization

A type variable itself cannot be explicitly specialized; the result of the specialization is Unknown. However, generic type aliases that point to type variables can be explicitly specialized.

py
type Positive[T] = T

def _[T](
    # error: [invalid-type-form] "A type variable itself cannot be specialized"
    a: T[int],
    # error: [invalid-type-form] "A type variable itself cannot be specialized"
    b: T[T],
    c: Positive[int],
):
    reveal_type(a)  # revealed: Unknown
    reveal_type(b)  # revealed: Unknown
    reveal_type(c)  # revealed: int

Invalid uses

Note that many of the invalid uses of legacy typevars do not apply to PEP 695 typevars, since the PEP 695 syntax is only allowed places where typevars are allowed.

Displaying typevars

We use a suffix when displaying the typevars of a generic function or class. This helps distinguish different uses of the same typevar.

py
def f[T](x: T, y: T) -> None:
    reveal_type(x)  # revealed: T@f

class C[T]:
    def m(self, x: T) -> None:
        reveal_type(x)  # revealed: T@C

Subtyping and assignability

(Note: for simplicity, all of the prose in this section refers to subtyping involving fully static typevars. Unless otherwise noted, all of the claims also apply to assignability involving gradual typevars.)

We can make no assumption about what type an unbounded, unconstrained, fully static typevar will be specialized to. Properties are true of the typevar only if they are true for every valid specialization. Thus, the typevar is a subtype of itself and of object, but not of any other type (including other typevars).

py
from ty_extensions import is_assignable_to, is_subtype_of, static_assert

class Super: ...
class Base(Super): ...
class Sub(Base): ...
class Unrelated: ...

def unbounded_unconstrained[T, U](t: T, u: U) -> None:
    static_assert(is_assignable_to(T, T))
    static_assert(is_assignable_to(T, object))
    static_assert(not is_assignable_to(T, Super))
    static_assert(is_assignable_to(T, Any))
    static_assert(is_assignable_to(Any, T))
    static_assert(is_assignable_to(U, U))
    static_assert(is_assignable_to(U, object))
    static_assert(not is_assignable_to(U, Super))
    static_assert(not is_assignable_to(T, U))
    static_assert(not is_assignable_to(U, T))

    static_assert(is_subtype_of(T, T))
    static_assert(is_subtype_of(T, object))
    static_assert(not is_subtype_of(T, Super))
    static_assert(not is_subtype_of(T, Any))
    static_assert(not is_subtype_of(Any, T))
    static_assert(is_subtype_of(U, U))
    static_assert(is_subtype_of(U, object))
    static_assert(not is_subtype_of(U, Super))
    static_assert(not is_subtype_of(T, U))
    static_assert(not is_subtype_of(U, T))

A bounded typevar is assignable to its bound, and a bounded, fully static typevar is a subtype of its bound. (A typevar with a non-fully-static bound is itself non-fully-static, and therefore does not participate in subtyping.) A fully static bound is not assignable to, nor a subtype of, the typevar, since the typevar might be specialized to a smaller type. (This is true even if the bound is a final class, since the typevar can still be specialized to Never.)

py
from typing import Any
from typing_extensions import final

def bounded[T: Super](t: T) -> None:
    static_assert(is_assignable_to(T, Any))
    static_assert(is_assignable_to(Any, T))
    static_assert(is_assignable_to(T, Super))
    static_assert(not is_assignable_to(T, Sub))
    static_assert(not is_assignable_to(Super, T))
    static_assert(not is_assignable_to(Sub, T))

    static_assert(not is_subtype_of(T, Any))
    static_assert(not is_subtype_of(Any, T))
    static_assert(is_subtype_of(T, Super))
    static_assert(not is_subtype_of(T, Sub))
    static_assert(not is_subtype_of(Super, T))
    static_assert(not is_subtype_of(Sub, T))

def bounded_by_gradual[T: Any](t: T) -> None:
    static_assert(is_assignable_to(T, Any))
    static_assert(is_assignable_to(Any, T))
    static_assert(is_assignable_to(T, Super))
    static_assert(not is_assignable_to(Super, T))
    static_assert(is_assignable_to(T, Sub))
    static_assert(not is_assignable_to(Sub, T))

    static_assert(not is_subtype_of(T, Any))
    static_assert(not is_subtype_of(Any, T))
    static_assert(not is_subtype_of(T, Super))
    static_assert(not is_subtype_of(Super, T))
    static_assert(not is_subtype_of(T, Sub))
    static_assert(not is_subtype_of(Sub, T))

@final
class FinalClass: ...

def bounded_final[T: FinalClass](t: T) -> None:
    static_assert(is_assignable_to(T, Any))
    static_assert(is_assignable_to(Any, T))
    static_assert(is_assignable_to(T, FinalClass))
    static_assert(not is_assignable_to(FinalClass, T))

    static_assert(not is_subtype_of(T, Any))
    static_assert(not is_subtype_of(Any, T))
    static_assert(is_subtype_of(T, FinalClass))
    static_assert(not is_subtype_of(FinalClass, T))

Two distinct fully static typevars are not subtypes of each other, even if they have the same bounds, since there is (still) no guarantee that they will be specialized to the same type. This is true even if both typevars are bounded by the same final class, since you can specialize the typevars to Never in addition to that final class.

py
def two_bounded[T: Super, U: Super](t: T, u: U) -> None:
    static_assert(not is_assignable_to(T, U))
    static_assert(not is_assignable_to(U, T))

    static_assert(not is_subtype_of(T, U))
    static_assert(not is_subtype_of(U, T))

def two_final_bounded[T: FinalClass, U: FinalClass](t: T, u: U) -> None:
    static_assert(not is_assignable_to(T, U))
    static_assert(not is_assignable_to(U, T))

    static_assert(not is_subtype_of(T, U))
    static_assert(not is_subtype_of(U, T))

A constrained fully static typevar is assignable to the union of its constraints, but not to any of the constraints individually. None of the constraints are subtypes of the typevar, though the intersection of all of its constraints is a subtype of the typevar.

py
from ty_extensions import Intersection

def constrained[T: (Base, Unrelated)](t: T) -> None:
    static_assert(not is_assignable_to(T, Super))
    static_assert(not is_assignable_to(T, Base))
    static_assert(not is_assignable_to(T, Sub))
    static_assert(not is_assignable_to(T, Unrelated))
    static_assert(is_assignable_to(T, Any))
    static_assert(is_assignable_to(T, Super | Unrelated))
    static_assert(is_assignable_to(T, Base | Unrelated))
    static_assert(not is_assignable_to(T, Sub | Unrelated))
    static_assert(is_assignable_to(Any, T))
    static_assert(not is_assignable_to(Super, T))
    static_assert(not is_assignable_to(Unrelated, T))
    static_assert(not is_assignable_to(Super | Unrelated, T))
    static_assert(is_assignable_to(Intersection[Base, Unrelated], T))

    static_assert(not is_subtype_of(T, Super))
    static_assert(not is_subtype_of(T, Base))
    static_assert(not is_subtype_of(T, Sub))
    static_assert(not is_subtype_of(T, Unrelated))
    static_assert(not is_subtype_of(T, Any))
    static_assert(is_subtype_of(T, Super | Unrelated))
    static_assert(is_subtype_of(T, Base | Unrelated))
    static_assert(not is_subtype_of(T, Sub | Unrelated))
    static_assert(not is_subtype_of(Any, T))
    static_assert(not is_subtype_of(Super, T))
    static_assert(not is_subtype_of(Unrelated, T))
    static_assert(not is_subtype_of(Super | Unrelated, T))
    static_assert(is_subtype_of(Intersection[Base, Unrelated], T))

def constrained_by_gradual[T: (Base, Any)](t: T) -> None:
    static_assert(is_assignable_to(T, Super))
    static_assert(is_assignable_to(T, Base))
    static_assert(not is_assignable_to(T, Sub))
    static_assert(not is_assignable_to(T, Unrelated))
    static_assert(is_assignable_to(T, Any))
    static_assert(is_assignable_to(T, Super | Any))
    static_assert(is_assignable_to(T, Super | Unrelated))
    static_assert(not is_assignable_to(Super, T))
    static_assert(is_assignable_to(Base, T))
    static_assert(not is_assignable_to(Unrelated, T))
    static_assert(is_assignable_to(Any, T))
    static_assert(not is_assignable_to(Super | Any, T))
    static_assert(is_assignable_to(Base | Any, T))
    static_assert(not is_assignable_to(Super | Unrelated, T))
    static_assert(is_assignable_to(Intersection[Base, Unrelated], T))
    static_assert(is_assignable_to(Intersection[Base, Any], T))

    static_assert(not is_subtype_of(T, Super))
    static_assert(not is_subtype_of(T, Base))
    static_assert(not is_subtype_of(T, Sub))
    static_assert(not is_subtype_of(T, Unrelated))
    static_assert(not is_subtype_of(T, Any))
    static_assert(not is_subtype_of(T, Super | Any))
    static_assert(not is_subtype_of(T, Super | Unrelated))
    static_assert(not is_subtype_of(Super, T))
    static_assert(not is_subtype_of(Base, T))
    static_assert(not is_subtype_of(Unrelated, T))
    static_assert(not is_subtype_of(Any, T))
    static_assert(not is_subtype_of(Super | Any, T))
    static_assert(not is_subtype_of(Base | Any, T))
    static_assert(not is_subtype_of(Super | Unrelated, T))
    static_assert(not is_subtype_of(Intersection[Base, Unrelated], T))
    static_assert(not is_subtype_of(Intersection[Base, Any], T))

Two distinct fully static typevars are not subtypes of each other, even if they have the same constraints, and even if any of the constraints are final. There must always be at least two distinct constraints, meaning that there is (still) no guarantee that they will be specialized to the same type.

py
def two_constrained[T: (int, str), U: (int, str)](t: T, u: U) -> None:
    static_assert(not is_assignable_to(T, U))
    static_assert(not is_assignable_to(U, T))

    static_assert(not is_subtype_of(T, U))
    static_assert(not is_subtype_of(U, T))

@final
class AnotherFinalClass: ...

def two_final_constrained[T: (FinalClass, AnotherFinalClass), U: (FinalClass, AnotherFinalClass)](t: T, u: U) -> None:
    static_assert(not is_assignable_to(T, U))
    static_assert(not is_assignable_to(U, T))

    static_assert(not is_subtype_of(T, U))
    static_assert(not is_subtype_of(U, T))

A bound or constrained typevar is a subtype of itself in a union:

py
def union[T: Base, U: (Base, Unrelated)](t: T, u: U) -> None:
    static_assert(is_assignable_to(T, T | None))
    static_assert(is_assignable_to(U, U | None))

    static_assert(is_subtype_of(T, T | None))
    static_assert(is_subtype_of(U, U | None))

A bound or constrained typevar in a union with a dynamic type is assignable to the typevar:

py
def union_with_dynamic[T: Base, U: (Base, Unrelated)](t: T, u: U) -> None:
    static_assert(is_assignable_to(T | Any, T))
    static_assert(is_assignable_to(U | Any, U))

    static_assert(not is_subtype_of(T | Any, T))
    static_assert(not is_subtype_of(U | Any, U))

And an intersection of a typevar with another type is always a subtype of the TypeVar:

py
from ty_extensions import Intersection, Not, is_disjoint_from

class A: ...

def inter[T: Base, U: (Base, Unrelated)](t: T, u: U) -> None:
    static_assert(is_assignable_to(Intersection[T, Unrelated], T))
    static_assert(is_subtype_of(Intersection[T, Unrelated], T))
    static_assert(is_assignable_to(Intersection[U, A], U))
    static_assert(is_subtype_of(Intersection[U, A], U))

    static_assert(is_disjoint_from(Not[T], T))
    static_assert(is_disjoint_from(T, Not[T]))
    static_assert(is_disjoint_from(Not[U], U))
    static_assert(is_disjoint_from(U, Not[U]))

Equivalence

A TypeVar is always equivalent to itself, but never to another TypeVar, since there is no guarantee that they will be specialized to the same type. (This is true even if both typevars are bounded by the same final class, since you can specialize the typevars to Never in addition to that final class.)

py
from typing import final
from ty_extensions import is_equivalent_to, static_assert

@final
class FinalClass: ...

@final
class SecondFinalClass: ...

def f[A, B, C: FinalClass, D: FinalClass, E: (FinalClass, SecondFinalClass), F: (FinalClass, SecondFinalClass)]():
    static_assert(is_equivalent_to(A, A))
    static_assert(is_equivalent_to(B, B))
    static_assert(is_equivalent_to(C, C))
    static_assert(is_equivalent_to(D, D))
    static_assert(is_equivalent_to(E, E))
    static_assert(is_equivalent_to(F, F))

    static_assert(not is_equivalent_to(A, B))
    static_assert(not is_equivalent_to(C, D))
    static_assert(not is_equivalent_to(E, F))

TypeVars which have non-fully-static bounds or constraints are also self-equivalent.

py
from typing import final, Any
from ty_extensions import is_equivalent_to, static_assert

# fmt: off

def f[
    A: tuple[Any],
    B: tuple[Any],
    C: (tuple[Any], tuple[Any, Any]),
    D: (tuple[Any], tuple[Any, Any])
]():
    static_assert(is_equivalent_to(A, A))
    static_assert(is_equivalent_to(B, B))
    static_assert(is_equivalent_to(C, C))
    static_assert(is_equivalent_to(D, D))

# fmt: on

Singletons and single-valued types

(Note: for simplicity, all of the prose in this section refers to singleton types, but all of the claims also apply to single-valued types.)

An unbounded, unconstrained typevar is not a singleton, because it can be specialized to a non-singleton type.

py
from ty_extensions import is_singleton, is_single_valued, static_assert

def unbounded_unconstrained[T](t: T) -> None:
    static_assert(not is_singleton(T))
    static_assert(not is_single_valued(T))

A bounded typevar is not a singleton, even if its bound is a singleton, since it can still be specialized to Never.

py
def bounded[T: None](t: T) -> None:
    static_assert(not is_singleton(T))
    static_assert(not is_single_valued(T))

A constrained typevar is a singleton if all of its constraints are singletons. (Note that you cannot specialize a constrained typevar to a subtype of a constraint.)

py
from typing_extensions import Literal

def constrained_non_singletons[T: (int, str)](t: T) -> None:
    static_assert(not is_singleton(T))
    static_assert(not is_single_valued(T))

def constrained_singletons[T: (Literal[True], Literal[False])](t: T) -> None:
    static_assert(is_singleton(T))

def constrained_single_valued[T: (Literal[True], tuple[()])](t: T) -> None:
    static_assert(is_single_valued(T))

Unions involving typevars

The union of an unbounded unconstrained typevar with any other type cannot be simplified, since there is no guarantee what type the typevar will be specialized to.

py
from typing import Any

class Super: ...
class Base(Super): ...
class Sub(Base): ...
class Unrelated: ...

def unbounded_unconstrained[T](t: T) -> None:
    def _(x: T | Super) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained | Super

    def _(x: T | Base) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained | Base

    def _(x: T | Sub) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained | Sub

    def _(x: T | Unrelated) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained | Unrelated

    def _(x: T | Any) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained | Any

The union of a bounded typevar with its bound is that bound. (The typevar is guaranteed to be specialized to a subtype of the bound.) The union of a bounded typevar with a subtype of its bound cannot be simplified. (The typevar might be specialized to a different subtype of the bound.)

py
def bounded[T: Base](t: T) -> None:
    def _(x: T | Super) -> None:
        reveal_type(x)  # revealed: Super

    def _(x: T | Base) -> None:
        reveal_type(x)  # revealed: Base

    def _(x: T | Sub) -> None:
        reveal_type(x)  # revealed: T@bounded | Sub

    def _(x: T | Unrelated) -> None:
        reveal_type(x)  # revealed: T@bounded | Unrelated

    def _(x: T | Any) -> None:
        reveal_type(x)  # revealed: T@bounded | Any

The union of a constrained typevar with a type depends on how that type relates to the constraints. If all of the constraints are a subtype of that type, the union simplifies to that type. Inversely, if the type is a subtype of every constraint, the union simplifies to the typevar. Otherwise, the union cannot be simplified.

py
def constrained[T: (Base, Sub)](t: T) -> None:
    def _(x: T | Super) -> None:
        reveal_type(x)  # revealed: Super

    def _(x: T | Base) -> None:
        reveal_type(x)  # revealed: Base

    def _(x: T | Sub) -> None:
        reveal_type(x)  # revealed: T@constrained

    def _(x: T | Unrelated) -> None:
        reveal_type(x)  # revealed: T@constrained | Unrelated

    def _(x: T | Any) -> None:
        reveal_type(x)  # revealed: T@constrained | Any

Intersections involving typevars

The intersection of an unbounded unconstrained typevar with any other type cannot be simplified, since there is no guarantee what type the typevar will be specialized to.

py
from ty_extensions import Intersection
from typing import Any

class Super: ...
class Base(Super): ...
class Sub(Base): ...
class Unrelated: ...

def unbounded_unconstrained[T](t: T) -> None:
    def _(x: Intersection[T, Super]) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained & Super

    def _(x: Intersection[T, Base]) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained & Base

    def _(x: Intersection[T, Sub]) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained & Sub

    def _(x: Intersection[T, Unrelated]) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained & Unrelated

    def _(x: Intersection[T, Any]) -> None:
        reveal_type(x)  # revealed: T@unbounded_unconstrained & Any

The intersection of a bounded typevar with its bound or a supertype of its bound is the typevar itself. (The typevar might be specialized to a subtype of the bound.) The intersection of a bounded typevar with a subtype of its bound cannot be simplified. (The typevar might be specialized to a different subtype of the bound.) The intersection of a bounded typevar with a type that is disjoint from its bound is Never.

py
def bounded[T: Base](t: T) -> None:
    def _(x: Intersection[T, Super]) -> None:
        reveal_type(x)  # revealed: T@bounded

    def _(x: Intersection[T, Base]) -> None:
        reveal_type(x)  # revealed: T@bounded

    def _(x: Intersection[T, Sub]) -> None:
        reveal_type(x)  # revealed: T@bounded & Sub

    def _(x: Intersection[T, None]) -> None:
        reveal_type(x)  # revealed: Never

    def _(x: Intersection[T, Any]) -> None:
        reveal_type(x)  # revealed: T@bounded & Any

Constrained typevars can be modeled using a hypothetical OneOf connector, where the typevar must be specialized to one of its constraints. The typevar is not the union of those constraints, since that would allow the typevar to take on values from multiple constraints simultaneously. The OneOf connector would not be a “type” according to a strict reading of the typing spec, since it would not represent a single set of runtime objects; it would instead represent a set of sets of runtime objects. This is one reason we have not actually added this connector to our data model yet. Nevertheless, describing constrained typevars this way helps explain how we simplify intersections involving them.

This means that when intersecting a constrained typevar with a type T, constraints that are supertypes of T can be simplified to T, since intersection distributes over OneOf. Moreover, constraints that are disjoint from T are no longer valid specializations of the typevar, since Never is an identity for OneOf. Even if only one compatible constraint remains, we preserve the typevar itself in the intersection so other occurrences of the same typevar stay correlated.

py
def constrained[T: (Base, Sub, Unrelated)](t: T) -> None:
    def _(x: Intersection[T, Base]) -> None:
        # With OneOf this would be OneOf[Base, Sub]
        reveal_type(x)  # revealed: T@constrained & Base

    def _(x: Intersection[T, Unrelated]) -> None:
        reveal_type(x)  # revealed: T@constrained & Unrelated

    def _(x: Intersection[T, Sub]) -> None:
        reveal_type(x)  # revealed: T@constrained & Sub

    def _(x: Intersection[T, None]) -> None:
        reveal_type(x)  # revealed: Never

    def _(x: Intersection[T, Any]) -> None:
        reveal_type(x)  # revealed: T@constrained & Any

We can simplify the intersection similarly when removing a type from a constrained typevar, since this is modeled internally as an intersection with a negation.

py
from ty_extensions import Not

def remove_constraint[T: (int, str, bool)](t: T) -> None:
    def _(x: Intersection[T, Not[int]]) -> None:
        reveal_type(x)  # revealed: T@remove_constraint & str

    def _(x: Intersection[T, Not[str]]) -> None:
        # With OneOf this would be OneOf[int, bool]
        reveal_type(x)  # revealed: T@remove_constraint & ~str

    def _(x: Intersection[T, Not[bool]]) -> None:
        reveal_type(x)  # revealed: T@remove_constraint & ~bool

    def _(x: Intersection[T, Not[int], Not[str]]) -> None:
        reveal_type(x)  # revealed: Never

    def _(x: Intersection[T, Not[None]]) -> None:
        reveal_type(x)  # revealed: T@remove_constraint

    def _(x: Intersection[T, Not[Any]]) -> None:
        reveal_type(x)  # revealed: T@remove_constraint & Any

The intersection of a typevar with any other type is assignable to (and if fully static, a subtype of) itself.

py
from ty_extensions import is_assignable_to, is_subtype_of, Not, static_assert

def intersection_is_assignable[T](t: T) -> None:
    static_assert(is_assignable_to(Intersection[T, None], T))
    static_assert(is_assignable_to(Intersection[T, Not[None]], T))

    static_assert(is_subtype_of(Intersection[T, None], T))
    static_assert(is_subtype_of(Intersection[T, Not[None]], T))

Bounded typevars remain assignable to their upper bound after narrowing

Narrowing can leave a bounded typevar represented as an intersection, but it should still be assignable to its upper bound.

py
from typing import Callable
from ty_extensions import Intersection, Not

class A: ...

class SomeClass[T: int | str]:
    field: T

    def narrowed1(self) -> None:
        narrowed: int | str
        assert not isinstance(self.field, int)
        reveal_type(self.field)  # revealed: T@SomeClass & ~int
        narrowed = self.field

    def narrowed2(self) -> None:
        narrowed: int | str
        assert not isinstance(self.field, A)
        reveal_type(self.field)  # revealed: T@SomeClass & ~A
        narrowed = self.field

def lenient_issubclass[T: type | tuple[type, ...]](class_or_tuple: T) -> T:
    if not isinstance(class_or_tuple, tuple):
        reveal_type(class_or_tuple)  # revealed: T@lenient_issubclass & ~tuple[object, ...]
        # `T@lenient_issubclass & ~tuple[object, ...]` is assignable to `type`,
        # because `(type | tuple[type, ...]) & ~tuple[object, ...]` simplifies to `type`
        return check(class_or_tuple)
    return class_or_tuple

def check(check_type: type): ...

# In this scenario, we do not expand the intersection,
# because it only has inferrable type variables in it.
# This ensures that we continue to infer a precise type on the last line here:
def higher[U](f: Callable[[U], type]) -> U:
    raise NotImplementedError

def source[T: type | tuple[type, ...]](x: T) -> Intersection[T, Not[tuple[object, ...]]]:
    raise NotImplementedError

reveal_type(higher(source))  # revealed: type

Constrained typevars remain assignable to the union of their constraints after narrowing

py
class A: ...

class SomeClass[T: (int, str)]:
    field: T

    def narrowed1(self) -> None:
        narrowed: int | str
        assert not isinstance(self.field, int)
        reveal_type(self.field)  # revealed: T@SomeClass & str
        narrowed = self.field

    def narrowed2(self) -> None:
        narrowed: int | str
        assert not isinstance(self.field, A)
        reveal_type(self.field)  # revealed: T@SomeClass & ~A
        narrowed = self.field

Narrowing

We can use narrowing expressions to eliminate some of the possibilities of a constrained typevar:

py
class P: ...
class Q: ...
class R: ...

def f[T: (P, Q)](t: T) -> None:
    if isinstance(t, P):
        reveal_type(t)  # revealed: T@f & P
        p: P = t
    else:
        reveal_type(t)  # revealed: T@f & Q & ~P
        q: Q = t

    if isinstance(t, Q):
        reveal_type(t)  # revealed: T@f & Q
        q: Q = t
    else:
        reveal_type(t)  # revealed: T@f & P & ~Q
        p: P = t

def g[T: (P, Q, R)](t: T) -> None:
    if isinstance(t, P):
        reveal_type(t)  # revealed: T@g & P
        p: P = t
    elif isinstance(t, Q):
        reveal_type(t)  # revealed: T@g & Q & ~P
        q: Q = t
    else:
        reveal_type(t)  # revealed: T@g & R & ~P & ~Q
        r: R = t

    if isinstance(t, P):
        reveal_type(t)  # revealed: T@g & P
        p: P = t
    elif isinstance(t, Q):
        reveal_type(t)  # revealed: T@g & Q & ~P
        q: Q = t
    elif isinstance(t, R):
        reveal_type(t)  # revealed: T@g & R & ~P & ~Q
        r: R = t
    else:
        reveal_type(t)  # revealed: Never

If the constraints are disjoint, simplification does eliminate the redundant negative:

py
def h[T: (P, None)](t: T) -> None:
    if t is None:
        reveal_type(t)  # revealed: T@h & None
        p: None = t
    else:
        reveal_type(t)  # revealed: T@h & P
        p: P = t

Callability

A typevar bound to a Callable type is callable:

py
from typing import Callable

def bound[T: Callable[[], int]](f: T):
    reveal_type(f)  # revealed: T@bound
    reveal_type(f())  # revealed: int

Same with a constrained typevar, as long as all constraints are callable:

py
def constrained[T: (Callable[[], int], Callable[[], str])](f: T):
    reveal_type(f)  # revealed: T@constrained
    reveal_type(f())  # revealed: int | str

Meta-type

The meta-type of a typevar is type[T].

py
def normal[T](x: T):
    reveal_type(type(x))  # revealed: type[T@normal]

def bound_object[T: object](x: T):
    reveal_type(type(x))  # revealed: type[T@bound_object]

def bound_int[T: int](x: T):
    reveal_type(type(x))  # revealed: type[T@bound_int]

def constrained[T: (int, str)](x: T):
    reveal_type(type(x))  # revealed: type[T@constrained]

Cycles

Bounds and constraints

A typevar's bounds and constraints cannot be generic, cyclic or otherwise:

py
from typing import Any

# error: [invalid-type-variable-bound]
def f[S, T: list[S]](x: S, y: T) -> S | T:
    return x or y

# error: [invalid-type-variable-bound]
class C[S, T: list[S]]:
    x: S
    y: T

reveal_type(C[int, list[Any]]().x)  # revealed: int
reveal_type(C[int, list[Any]]().y)  # revealed: list[Any]

# error: [invalid-type-variable-bound]
def g[T: list[T]](x: T) -> T:
    return x

# error: [invalid-type-variable-bound]
class D[T: list[T]]:
    x: T

reveal_type(D[list[Any]]().x)  # revealed: list[Any]

# error: [invalid-type-variable-constraints]
def h[S, T: (list[S], str)](x: S, y: T) -> S | T:
    return x or y

# error: [invalid-type-variable-constraints]
class E[S, T: (list[S], str)]:
    x: S
    y: T

reveal_type(E[int, str]().x)  # revealed: int
reveal_type(E[int, str]().y)  # revealed: str

# error: [invalid-type-variable-constraints]
def i[T: (list[T], str)](x: T) -> T:
    return x

# error: [invalid-type-variable-constraints]
class F[T: (list[T], str)]:
    x: T

reveal_type(F[list[Any]]().x)  # revealed: list[Any]

However, they are lazily evaluated and can cyclically refer to their own type:

py
class G[T: list[G]]:
    x: T

reveal_type(G[list[G]]().x)  # revealed: list[G[Unknown]]

An invalid specialization in a recursive bound doesn't cause a panic:

py
# error: [invalid-type-arguments]
class Node[T: "Node[int]"]:
    pass

# error: [invalid-type-arguments]
def _(n: Node[str]):
    reveal_type(n)  # revealed: Node[Unknown]

Defaults

Defaults can be generic, but can only refer to earlier typevars:

py
class C[T, U = T]:
    x: T
    y: U

reveal_type(C[int, str]().x)  # revealed: int
reveal_type(C[int, str]().y)  # revealed: str
reveal_type(C[int]().x)  # revealed: int
reveal_type(C[int]().y)  # revealed: int

# TODO: error
class D[T = T]:
    x: T

reveal_type(D().x)  # revealed: Unknown