[ty] Implicit type aliases: Support for PEP 604 unions (#21195)

## Summary

Add support for implicit type aliases that use PEP 604 unions:
```py
IntOrStr = int | str

reveal_type(IntOrStr)  # UnionType

def _(int_or_str: IntOrStr):
    reveal_type(int_or_str)  # int | str
```

## Typing conformance

The changes are either removed false positives, or new diagnostics due
to known limitations unrelated to this PR.

## Ecosystem impact

Spot checked, a mix of true positives and known limitations.

## Test Plan

New Markdown tests.
This commit is contained in:
David Peter 2025-11-03 21:50:25 +01:00 committed by GitHub
parent fe4ee81b97
commit 1fe958c694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 334 additions and 36 deletions

View file

@ -72,9 +72,6 @@ def f(x: Union) -> None:
## Implicit type aliases using new-style unions
We don't recognize these as type aliases yet, but we also don't emit false-positive diagnostics if
you use them in type expressions:
```toml
[environment]
python-version = "3.10"
@ -84,5 +81,5 @@ python-version = "3.10"
X = int | str
def f(y: X):
reveal_type(y) # revealed: @Todo(Support for `types.UnionType` instances in type expressions)
reveal_type(y) # revealed: int | str
```

View file

@ -17,6 +17,209 @@ def f(x: MyInt):
f(1)
```
## None
```py
MyNone = None
# TODO: this should not be an error
# error: [invalid-type-form] "Variable of type `None` is not allowed in a type expression"
def g(x: MyNone):
# TODO: this should be `None`
reveal_type(x) # revealed: Unknown
g(None)
```
## Unions
We also support unions in type aliases:
```py
from typing_extensions import Any, Never
from ty_extensions import Unknown
IntOrStr = int | str
IntOrStrOrBytes1 = int | str | bytes
IntOrStrOrBytes2 = (int | str) | bytes
IntOrStrOrBytes3 = int | (str | bytes)
IntOrStrOrBytes4 = IntOrStr | bytes
BytesOrIntOrStr = bytes | IntOrStr
IntOrNone = int | None
NoneOrInt = None | int
IntOrStrOrNone = IntOrStr | None
NoneOrIntOrStr = None | IntOrStr
IntOrAny = int | Any
AnyOrInt = Any | int
NoneOrAny = None | Any
AnyOrNone = Any | None
NeverOrAny = Never | Any
AnyOrNever = Any | Never
UnknownOrInt = Unknown | int
IntOrUnknown = int | Unknown
reveal_type(IntOrStr) # revealed: UnionType
reveal_type(IntOrStrOrBytes1) # revealed: UnionType
reveal_type(IntOrStrOrBytes2) # revealed: UnionType
reveal_type(IntOrStrOrBytes3) # revealed: UnionType
reveal_type(IntOrStrOrBytes4) # revealed: UnionType
reveal_type(BytesOrIntOrStr) # revealed: UnionType
reveal_type(IntOrNone) # revealed: UnionType
reveal_type(NoneOrInt) # revealed: UnionType
reveal_type(IntOrStrOrNone) # revealed: UnionType
reveal_type(NoneOrIntOrStr) # revealed: UnionType
reveal_type(IntOrAny) # revealed: UnionType
reveal_type(AnyOrInt) # revealed: UnionType
reveal_type(NoneOrAny) # revealed: UnionType
reveal_type(AnyOrNone) # revealed: UnionType
reveal_type(NeverOrAny) # revealed: UnionType
reveal_type(AnyOrNever) # revealed: UnionType
reveal_type(UnknownOrInt) # revealed: UnionType
reveal_type(IntOrUnknown) # revealed: UnionType
def _(
int_or_str: IntOrStr,
int_or_str_or_bytes1: IntOrStrOrBytes1,
int_or_str_or_bytes2: IntOrStrOrBytes2,
int_or_str_or_bytes3: IntOrStrOrBytes3,
int_or_str_or_bytes4: IntOrStrOrBytes4,
bytes_or_int_or_str: BytesOrIntOrStr,
int_or_none: IntOrNone,
none_or_int: NoneOrInt,
int_or_str_or_none: IntOrStrOrNone,
none_or_int_or_str: NoneOrIntOrStr,
int_or_any: IntOrAny,
any_or_int: AnyOrInt,
none_or_any: NoneOrAny,
any_or_none: AnyOrNone,
never_or_any: NeverOrAny,
any_or_never: AnyOrNever,
unknown_or_int: UnknownOrInt,
int_or_unknown: IntOrUnknown,
):
reveal_type(int_or_str) # revealed: int | str
reveal_type(int_or_str_or_bytes1) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes2) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes3) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes4) # revealed: int | str | bytes
reveal_type(bytes_or_int_or_str) # revealed: bytes | int | str
reveal_type(int_or_none) # revealed: int | None
reveal_type(none_or_int) # revealed: None | int
reveal_type(int_or_str_or_none) # revealed: int | str | None
reveal_type(none_or_int_or_str) # revealed: None | int | str
reveal_type(int_or_any) # revealed: int | Any
reveal_type(any_or_int) # revealed: Any | int
reveal_type(none_or_any) # revealed: None | Any
reveal_type(any_or_none) # revealed: Any | None
reveal_type(never_or_any) # revealed: Any
reveal_type(any_or_never) # revealed: Any
reveal_type(unknown_or_int) # revealed: Unknown | int
reveal_type(int_or_unknown) # revealed: int | Unknown
```
If a type is unioned with itself in a value expression, the result is just that type. No
`types.UnionType` instance is created:
```py
IntOrInt = int | int
ListOfIntOrListOfInt = list[int] | list[int]
reveal_type(IntOrInt) # revealed: <class 'int'>
reveal_type(ListOfIntOrListOfInt) # revealed: <class 'list[int]'>
def _(int_or_int: IntOrInt, list_of_int_or_list_of_int: ListOfIntOrListOfInt):
reveal_type(int_or_int) # revealed: int
reveal_type(list_of_int_or_list_of_int) # revealed: list[int]
```
`NoneType` has no special or-operator behavior, so this is an error:
```py
None | None # error: [unsupported-operator] "Operator `|` is unsupported between objects of type `None` and `None`"
```
When constructing something non-sensical like `int | 1`, we could ideally emit a diagnostic for the
expression itself, as it leads to a `TypeError` at runtime. No other type checker supports this, so
for now we only emit an error when it is used in a type expression:
```py
IntOrOne = int | 1
# error: [invalid-type-form] "Variable of type `Literal[1]` is not allowed in a type expression"
def _(int_or_one: IntOrOne):
reveal_type(int_or_one) # revealed: Unknown
```
If you were to somehow get hold of an opaque instance of `types.UnionType`, that could not be used
as a type expression:
```py
from types import UnionType
def f(SomeUnionType: UnionType):
# error: [invalid-type-form] "Variable of type `UnionType` is not allowed in a type expression"
some_union: SomeUnionType
f(int | str)
```
## Generic types
Implicit type aliases can also refer to generic types:
```py
from typing_extensions import TypeVar
T = TypeVar("T")
MyList = list[T]
def _(my_list: MyList[int]):
# TODO: This should be `list[int]`
reveal_type(my_list) # revealed: @Todo(unknown type subscript)
ListOrTuple = list[T] | tuple[T, ...]
reveal_type(ListOrTuple) # revealed: UnionType
def _(list_or_tuple: ListOrTuple[int]):
reveal_type(list_or_tuple) # revealed: @Todo(Generic specialization of types.UnionType)
```
## Stringified annotations?
From the [typing spec on type aliases](https://typing.python.org/en/latest/spec/aliases.html):
> Type aliases may be as complex as type hints in annotations anything that is acceptable as a
> type hint is acceptable in a type alias
However, no other type checker seems to support stringified annotations in implicit type aliases. We
currently also do not support them:
```py
AliasForStr = "str"
# error: [invalid-type-form] "Variable of type `Literal["str"]` is not allowed in a type expression"
def _(s: AliasForStr):
reveal_type(s) # revealed: Unknown
IntOrStr = int | "str"
# error: [invalid-type-form] "Variable of type `Literal["str"]` is not allowed in a type expression"
def _(int_or_str: IntOrStr):
reveal_type(int_or_str) # revealed: Unknown
```
We *do* support stringified annotations if they appear in a position where a type expression is
syntactically expected:
```py
ListOfInts = list["int"]
def _(list_of_ints: ListOfInts):
reveal_type(list_of_ints) # revealed: list[int]
```
## Recursive
### Old union syntax

View file

@ -291,6 +291,20 @@ class Foo(x): ...
reveal_mro(Foo) # revealed: (<class 'Foo'>, Unknown, <class 'object'>)
```
## `UnionType` instances are now allowed as a base
This is not legal:
```py
class A: ...
class B: ...
EitherOr = A | B
# error: [invalid-base] "Invalid class base with type `UnionType`"
class Foo(EitherOr): ...
```
## `__bases__` is a union of a dynamic type and valid bases
If a dynamic type such as `Any` or `Unknown` is one of the elements in the union, and all other

View file

@ -146,13 +146,11 @@ def _(flag: bool):
def _(flag: bool):
x = 1 if flag else "a"
# TODO: this should cause us to emit a diagnostic during
# type checking
# error: [invalid-argument-type] "Argument to function `isinstance` is incorrect: Expected `type | UnionType | tuple[Unknown, ...]`, found `Literal["a"]"
if isinstance(x, "a"):
reveal_type(x) # revealed: Literal[1, "a"]
# TODO: this should cause us to emit a diagnostic during
# type checking
# error: [invalid-argument-type] "Argument to function `isinstance` is incorrect: Expected `type | UnionType | tuple[Unknown, ...]`, found `Literal["int"]"
if isinstance(x, "int"):
reveal_type(x) # revealed: Literal[1, "a"]
```

View file

@ -214,8 +214,7 @@ def flag() -> bool:
t = int if flag() else str
# TODO: this should cause us to emit a diagnostic during
# type checking
# error: [invalid-argument-type] "Argument to function `issubclass` is incorrect: Expected `type | UnionType | tuple[Unknown, ...]`, found `Literal["str"]"
if issubclass(t, "str"):
reveal_type(t) # revealed: <class 'int'> | <class 'str'>