[ty] Implicit type aliases: Add support for typing.Union (#21363)

## Summary

Add support for `typing.Union` in implicit type aliases / in value
position.

## Typing conformance tests

Two new tests are passing

## Ecosystem impact

* The 2k new `invalid-key` diagnostics on pydantic are caused by
https://github.com/astral-sh/ty/issues/1479#issuecomment-3513854645.
* Everything else I've checked is either a known limitation (often
related to type narrowing, because union types are often narrowed down
to a subset of options), or a true positive.

## Test Plan

New Markdown tests
This commit is contained in:
David Peter 2025-11-12 12:59:14 +01:00 committed by GitHub
parent f5cf672ed4
commit e8e8180888
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 19 deletions

View file

@ -33,7 +33,7 @@ g(None)
We also support unions in type aliases:
```py
from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional
from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional, Union
from ty_extensions import Unknown
IntOrStr = int | str
@ -41,6 +41,8 @@ IntOrStrOrBytes1 = int | str | bytes
IntOrStrOrBytes2 = (int | str) | bytes
IntOrStrOrBytes3 = int | (str | bytes)
IntOrStrOrBytes4 = IntOrStr | bytes
IntOrStrOrBytes5 = int | Union[str, bytes]
IntOrStrOrBytes6 = Union[int, str] | bytes
BytesOrIntOrStr = bytes | IntOrStr
IntOrNone = int | None
NoneOrInt = None | int
@ -70,6 +72,8 @@ reveal_type(IntOrStrOrBytes1) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes2) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes3) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes4) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes5) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes6) # revealed: types.UnionType
reveal_type(BytesOrIntOrStr) # revealed: types.UnionType
reveal_type(IntOrNone) # revealed: types.UnionType
reveal_type(NoneOrInt) # revealed: types.UnionType
@ -100,6 +104,8 @@ def _(
int_or_str_or_bytes2: IntOrStrOrBytes2,
int_or_str_or_bytes3: IntOrStrOrBytes3,
int_or_str_or_bytes4: IntOrStrOrBytes4,
int_or_str_or_bytes5: IntOrStrOrBytes5,
int_or_str_or_bytes6: IntOrStrOrBytes6,
bytes_or_int_or_str: BytesOrIntOrStr,
int_or_none: IntOrNone,
none_or_int: NoneOrInt,
@ -129,6 +135,8 @@ def _(
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(int_or_str_or_bytes5) # revealed: int | str | bytes
reveal_type(int_or_str_or_bytes6) # 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
@ -505,13 +513,90 @@ def _(
## `Tuple`
We support implicit type aliases using `typing.Tuple`:
```py
from typing import Tuple
IntAndStr = Tuple[int, str]
SingleInt = Tuple[int]
Ints = Tuple[int, ...]
EmptyTuple = Tuple[()]
def _(int_and_str: IntAndStr):
def _(int_and_str: IntAndStr, single_int: SingleInt, ints: Ints, empty_tuple: EmptyTuple):
reveal_type(int_and_str) # revealed: tuple[int, str]
reveal_type(single_int) # revealed: tuple[int]
reveal_type(ints) # revealed: tuple[int, ...]
reveal_type(empty_tuple) # revealed: tuple[()]
```
Invalid uses cause diagnostics:
```py
from typing import Tuple
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Invalid = Tuple[int, 1]
def _(invalid: Invalid):
reveal_type(invalid) # revealed: tuple[int, Unknown]
```
## `Union`
We support implicit type aliases using `typing.Union`:
```py
from typing import Union
IntOrStr = Union[int, str]
IntOrStrOrBytes = Union[int, Union[str, bytes]]
reveal_type(IntOrStr) # revealed: types.UnionType
reveal_type(IntOrStrOrBytes) # revealed: types.UnionType
def _(
int_or_str: IntOrStr,
int_or_str_or_bytes: IntOrStrOrBytes,
):
reveal_type(int_or_str) # revealed: int | str
reveal_type(int_or_str_or_bytes) # revealed: int | str | bytes
```
If a single type is given, no `types.UnionType` instance is created:
```py
JustInt = Union[int]
reveal_type(JustInt) # revealed: <class 'int'>
def _(just_int: JustInt):
reveal_type(just_int) # revealed: int
```
An empty `typing.Union` leads to a `TypeError` at runtime, so we emit an error. We still infer
`Never` when used as a type expression, which seems reasonable for an empty union:
```py
# error: [invalid-type-form] "`typing.Union` requires at least one type argument"
EmptyUnion = Union[()]
reveal_type(EmptyUnion) # revealed: types.UnionType
def _(empty: EmptyUnion):
reveal_type(empty) # revealed: Never
```
Other invalid uses are also caught:
```py
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Invalid = Union[str, 1]
def _(
invalid: Invalid,
):
reveal_type(invalid) # revealed: str | Unknown
```
## Stringified annotations?
@ -544,10 +629,19 @@ We *do* support stringified annotations if they appear in a position where a typ
syntactically expected:
```py
ListOfInts = list["int"]
from typing import Union
def _(list_of_ints: ListOfInts):
ListOfInts = list["int"]
StrOrStyle = Union[str, "Style"]
class Style: ...
def _(
list_of_ints: ListOfInts,
str_or_style: StrOrStyle,
):
reveal_type(list_of_ints) # revealed: list[int]
reveal_type(str_or_style) # revealed: str | Style
```
## Recursive

View file

@ -104,3 +104,27 @@ from typing import Callable
def _(c: Callable[]):
reveal_type(c) # revealed: (...) -> Unknown
```
### `typing.Tuple`
```py
from typing import Tuple
# error: [invalid-syntax] "Expected index or slice expression"
InvalidEmptyTuple = Tuple[]
def _(t: InvalidEmptyTuple):
reveal_type(t) # revealed: tuple[Unknown]
```
### `typing.Union`
```py
from typing import Union
# error: [invalid-syntax] "Expected index or slice expression"
InvalidEmptyUnion = Union[]
def _(u: InvalidEmptyUnion):
reveal_type(u) # revealed: Unknown
```

View file

@ -6586,12 +6586,13 @@ impl<'db> Type<'db> {
}),
KnownInstanceType::UnionType(list) => {
let mut builder = UnionBuilder::new(db);
let inferred_as = list.inferred_as(db);
for element in list.elements(db) {
builder = builder.add(element.in_type_expression(
db,
scope_id,
typevar_binding_context,
)?);
builder = builder.add(if inferred_as.type_expression() {
*element
} else {
element.in_type_expression(db, scope_id, typevar_binding_context)?
});
}
Ok(builder.build())
}
@ -9164,6 +9165,21 @@ impl<'db> TypeVarBoundOrConstraints<'db> {
}
}
/// Whether a given type originates from value expression inference or type expression inference.
/// For example, the symbol `int` would be inferred as `<class 'int'>` in value expression context,
/// and as `int` (i.e. an instance of the class `int`) in type expression context.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::Update)]
pub enum InferredAs {
ValueExpression,
TypeExpression,
}
impl InferredAs {
pub const fn type_expression(self) -> bool {
matches!(self, InferredAs::TypeExpression)
}
}
/// A salsa-interned list of types.
///
/// # Ordering
@ -9174,6 +9190,7 @@ impl<'db> TypeVarBoundOrConstraints<'db> {
pub struct InternedTypes<'db> {
#[returns(deref)]
elements: Box<[Type<'db>]>,
inferred_as: InferredAs,
}
impl get_size2::GetSize for InternedTypes<'_> {}
@ -9182,8 +9199,9 @@ impl<'db> InternedTypes<'db> {
pub(crate) fn from_elements(
db: &'db dyn Db,
elements: impl IntoIterator<Item = Type<'db>>,
inferred_as: InferredAs,
) -> InternedTypes<'db> {
InternedTypes::new(db, elements.into_iter().collect::<Box<[_]>>())
InternedTypes::new(db, elements.into_iter().collect::<Box<[_]>>(), inferred_as)
}
pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self {
@ -9193,6 +9211,7 @@ impl<'db> InternedTypes<'db> {
.iter()
.map(|ty| ty.normalized_impl(db, visitor))
.collect::<Box<[_]>>(),
self.inferred_as(db),
)
}
}

View file

@ -101,10 +101,10 @@ use crate::types::typed_dict::{
use crate::types::visitor::any_over_type;
use crate::types::{
CallDunderError, CallableBinding, CallableType, ClassLiteral, ClassType, DataclassParams,
DynamicType, InternedType, InternedTypes, IntersectionBuilder, IntersectionType, KnownClass,
KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType, Parameter,
ParameterForm, Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet, Truthiness,
Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers,
DynamicType, InferredAs, InternedType, InternedTypes, IntersectionBuilder, IntersectionType,
KnownClass, KnownInstanceType, MemberLookupPolicy, MetaclassCandidate, PEP695TypeAliasType,
Parameter, ParameterForm, Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet,
Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers,
TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarIdentity,
TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder, UnionType,
binding_type, todo_type,
@ -9234,7 +9234,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
Some(left_ty)
} else {
Some(Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(self.db(), [left_ty, right_ty]),
InternedTypes::from_elements(
self.db(),
[left_ty, right_ty],
InferredAs::ValueExpression,
),
)))
}
}
@ -9259,7 +9263,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
&& instance.has_known_class(self.db(), KnownClass::NoneType) =>
{
Some(Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(self.db(), [left_ty, right_ty]),
InternedTypes::from_elements(
self.db(),
[left_ty, right_ty],
InferredAs::ValueExpression,
),
)))
}
@ -10476,9 +10484,46 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
return Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(self.db(), [ty, Type::none(self.db())]),
InternedTypes::from_elements(
self.db(),
[ty, Type::none(self.db())],
InferredAs::ValueExpression,
),
));
}
Type::SpecialForm(SpecialFormType::Union) => {
let db = self.db();
match **slice {
ast::Expr::Tuple(ref tuple) => {
let mut elements = tuple
.elts
.iter()
.map(|elt| self.infer_type_expression(elt))
.peekable();
let is_empty = elements.peek().is_none();
let union_type = Type::KnownInstance(KnownInstanceType::UnionType(
InternedTypes::from_elements(db, elements, InferredAs::TypeExpression),
));
if is_empty {
if let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, subscript)
{
builder.into_diagnostic(
"`typing.Union` requires at least one type argument",
);
}
}
return union_type;
}
_ => {
return self.infer_expression(slice, TypeContext::default());
}
}
}
_ => {}
}