[ty] Eagerly evaluate types.UnionType elements as type expressions (#21531)

## Summary

Eagerly evaluate the elements of a PEP 604 union in value position (e.g.
`IntOrStr = int | str`) as type expressions and store the result (the
corresponding `Type::Union` if all elements are valid type expressions,
or the first encountered `InvalidTypeExpressionError`) on the
`UnionTypeInstance`, such that the `Type::Union(…)` does not need to be
recomputed every time the implicit type alias is used in a type
annotation.

This might lead to performance improvements for large unions, but is
also necessary for correctness, because the elements of the union might
refer to type variables that need to be looked up in the scope of the
type alias, not at the usage site.

## Test Plan

New Markdown tests
This commit is contained in:
David Peter
2025-11-20 17:28:48 +01:00
committed by GitHub
parent 416e2267da
commit 0761ea42d9
7 changed files with 200 additions and 100 deletions

View File

@@ -191,13 +191,13 @@ def _(
reveal_type(int_or_callable) # revealed: int | ((str, /) -> bytes)
reveal_type(callable_or_int) # revealed: ((str, /) -> bytes) | int
# TODO should be Unknown | int
reveal_type(type_var_or_int) # revealed: T@_ | int
reveal_type(type_var_or_int) # revealed: typing.TypeVar | int
# TODO should be int | Unknown
reveal_type(int_or_type_var) # revealed: int | T@_
reveal_type(int_or_type_var) # revealed: int | typing.TypeVar
# TODO should be Unknown | None
reveal_type(type_var_or_none) # revealed: T@_ | None
reveal_type(type_var_or_none) # revealed: typing.TypeVar | None
# TODO should be None | Unknown
reveal_type(none_or_type_var) # revealed: None | T@_
reveal_type(none_or_type_var) # revealed: None | typing.TypeVar
```
If a type is unioned with itself in a value expression, the result is just that type. No

View File

@@ -159,19 +159,43 @@ IntOrStr = Union[int, str]
reveal_type(IntOrStr) # revealed: types.UnionType
def _(x: int | str | bytes | memoryview | range):
# TODO: no error
# error: [invalid-argument-type]
if isinstance(x, IntOrStr):
# TODO: Should be `int | str`
reveal_type(x) # revealed: int | str | bytes | memoryview[int] | range
# TODO: no error
# error: [invalid-argument-type]
reveal_type(x) # revealed: int | str
elif isinstance(x, Union[bytes, memoryview]):
# TODO: Should be `bytes | memoryview[int]`
reveal_type(x) # revealed: int | str | bytes | memoryview[int] | range
reveal_type(x) # revealed: bytes | memoryview[int]
else:
# TODO: Should be `range`
reveal_type(x) # revealed: int | str | bytes | memoryview[int] | range
reveal_type(x) # revealed: range
def _(x: int | str | None):
if isinstance(x, Union[int, None]):
reveal_type(x) # revealed: int | None
else:
reveal_type(x) # revealed: str
ListStrOrInt = Union[list[str], int]
def _(x: dict[int, str] | ListStrOrInt):
# TODO: this should ideally be an error
if isinstance(x, ListStrOrInt):
# TODO: this should not be narrowed
reveal_type(x) # revealed: list[str] | int
# TODO: this should ideally be an error
if isinstance(x, Union[list[str], int]):
# TODO: this should not be narrowed
reveal_type(x) # revealed: list[str] | int
```
## `Optional` as `classinfo`
```py
from typing import Optional
def _(x: int | str | None):
if isinstance(x, Optional[int]):
reveal_type(x) # revealed: int | None
else:
reveal_type(x) # revealed: str
```
## `classinfo` is a `typing.py` special form
@@ -289,6 +313,23 @@ def _(flag: bool):
reveal_type(x) # revealed: Literal[1, "a"]
```
## Generic aliases are not supported as second argument
The `classinfo` argument cannot be a generic alias:
```py
def _(x: list[str] | list[int] | list[bytes]):
# TODO: Ideally, this would be an error (requires https://github.com/astral-sh/ty/issues/116)
if isinstance(x, list[int]):
# No narrowing here:
reveal_type(x) # revealed: list[str] | list[int] | list[bytes]
# error: [invalid-argument-type] "Invalid second argument to `isinstance`"
if isinstance(x, list[int] | list[str]):
# No narrowing here:
reveal_type(x) # revealed: list[str] | list[int] | list[bytes]
```
## `type[]` types are narrowed as well as class-literal types
```py

View File

@@ -212,19 +212,12 @@ IntOrStr = Union[int, str]
reveal_type(IntOrStr) # revealed: types.UnionType
def f(x: type[int | str | bytes | range]):
# TODO: No error
# error: [invalid-argument-type]
if issubclass(x, IntOrStr):
# TODO: Should be `type[int] | type[str]`
reveal_type(x) # revealed: type[int] | type[str] | type[bytes] | <class 'range'>
# TODO: No error
# error: [invalid-argument-type]
reveal_type(x) # revealed: type[int] | type[str]
elif issubclass(x, Union[bytes, memoryview]):
# TODO: Should be `type[bytes]`
reveal_type(x) # revealed: type[int] | type[str] | type[bytes] | <class 'range'>
reveal_type(x) # revealed: type[bytes]
else:
# TODO: Should be `<class 'range'>`
reveal_type(x) # revealed: type[int] | type[str] | type[bytes] | <class 'range'>
reveal_type(x) # revealed: <class 'range'>
```
## Special cases