Skip to content

Commit 2d64871

Browse files
committed
[ty] Implicit type aliases: support for PEP 604 unions
1 parent 73107a0 commit 2d64871

File tree

12 files changed

+291
-36
lines changed

12 files changed

+291
-36
lines changed

crates/ruff_benchmark/benches/ty_walltime.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ static FREQTRADE: Benchmark = Benchmark::new(
146146
max_dep_date: "2025-06-17",
147147
python_version: PythonVersion::PY312,
148148
},
149-
400,
149+
500,
150150
);
151151

152152
static PANDAS: Benchmark = Benchmark::new(

crates/ty_python_semantic/resources/mdtest/annotations/union.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,6 @@ def f(x: Union) -> None:
7272

7373
## Implicit type aliases using new-style unions
7474

75-
We don't recognize these as type aliases yet, but we also don't emit false-positive diagnostics if
76-
you use them in type expressions:
77-
7875
```toml
7976
[environment]
8077
python-version = "3.10"
@@ -84,5 +81,5 @@ python-version = "3.10"
8481
X = int | str
8582

8683
def f(y: X):
87-
reveal_type(y) # revealed: @Todo(Support for `types.UnionType` instances in type expressions)
84+
reveal_type(y) # revealed: int | str
8885
```

crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,175 @@ def f(x: MyInt):
1717
f(1)
1818
```
1919

20+
## None
21+
22+
```py
23+
MyNone = None
24+
25+
# TODO: this should not be an error
26+
# error: [invalid-type-form] "Variable of type `None` is not allowed in a type expression"
27+
def g(x: MyNone):
28+
# TODO: this should be `None`
29+
reveal_type(x) # revealed: Unknown
30+
31+
g(None)
32+
```
33+
34+
## Unions
35+
36+
We also support unions in type aliases:
37+
38+
```py
39+
from typing_extensions import Any, Never
40+
from ty_extensions import Unknown
41+
42+
IntOrStr = int | str
43+
IntOrStrOrBytes1 = int | str | bytes
44+
IntOrStrOrBytes2 = (int | str) | bytes
45+
IntOrStrOrBytes3 = int | (str | bytes)
46+
IntOrStrOrBytes4 = IntOrStr | bytes
47+
BytesOrIntOrStr = bytes | IntOrStr
48+
IntOrNone = int | None
49+
NoneOrInt = None | int
50+
IntOrStrOrNone = IntOrStr | None
51+
NoneOrIntOrStr = None | IntOrStr
52+
IntOrAny = int | Any
53+
AnyOrInt = Any | int
54+
NoneOrAny = None | Any
55+
AnyOrNone = Any | None
56+
NeverOrAny = Never | Any
57+
AnyOrNever = Any | Never
58+
UnknownOrInt = Unknown | int
59+
IntOrUnknown = int | Unknown
60+
61+
def _(
62+
int_or_str: IntOrStr,
63+
int_or_str_or_bytes1: IntOrStrOrBytes1,
64+
int_or_str_or_bytes2: IntOrStrOrBytes2,
65+
int_or_str_or_bytes3: IntOrStrOrBytes3,
66+
int_or_str_or_bytes4: IntOrStrOrBytes4,
67+
bytes_or_int_or_str: BytesOrIntOrStr,
68+
int_or_none: IntOrNone,
69+
none_or_int: NoneOrInt,
70+
int_or_str_or_none: IntOrStrOrNone,
71+
none_or_int_or_str: NoneOrIntOrStr,
72+
int_or_any: IntOrAny,
73+
any_or_int: AnyOrInt,
74+
none_or_any: NoneOrAny,
75+
any_or_none: AnyOrNone,
76+
never_or_any: NeverOrAny,
77+
any_or_never: AnyOrNever,
78+
unknown_or_int: UnknownOrInt,
79+
int_or_unknown: IntOrUnknown,
80+
):
81+
reveal_type(int_or_str) # revealed: int | str
82+
reveal_type(int_or_str_or_bytes1) # revealed: int | str | bytes
83+
reveal_type(int_or_str_or_bytes2) # revealed: int | str | bytes
84+
reveal_type(int_or_str_or_bytes3) # revealed: int | str | bytes
85+
reveal_type(int_or_str_or_bytes4) # revealed: int | str | bytes
86+
reveal_type(bytes_or_int_or_str) # revealed: bytes | int | str
87+
reveal_type(int_or_none) # revealed: int | None
88+
reveal_type(none_or_int) # revealed: None | int
89+
reveal_type(int_or_str_or_none) # revealed: int | str | None
90+
reveal_type(none_or_int_or_str) # revealed: None | int | str
91+
reveal_type(int_or_any) # revealed: int | Any
92+
reveal_type(any_or_int) # revealed: Any | int
93+
reveal_type(none_or_any) # revealed: None | Any
94+
reveal_type(any_or_none) # revealed: Any | None
95+
reveal_type(never_or_any) # revealed: Any
96+
reveal_type(any_or_never) # revealed: Any
97+
reveal_type(unknown_or_int) # revealed: Unknown | int
98+
reveal_type(int_or_unknown) # revealed: int | Unknown
99+
```
100+
101+
If a type is unioned with itself in a value expression, the result is just that type. No
102+
`types.UnionType` instance is created:
103+
104+
```py
105+
IntOrInt = int | int
106+
ListOfIntOrListOfInt = list[int] | list[int]
107+
108+
reveal_type(IntOrInt) # revealed: <class 'int'>
109+
reveal_type(ListOfIntOrListOfInt) # revealed: <class 'list[int]'>
110+
111+
def _(int_or_int: IntOrInt, list_of_int_or_list_of_int: ListOfIntOrListOfInt):
112+
reveal_type(int_or_int) # revealed: int
113+
reveal_type(list_of_int_or_list_of_int) # revealed: list[int]
114+
```
115+
116+
`NoneType` has no special or-operator behavior, so this is an error:
117+
118+
```py
119+
None | None # error: [unsupported-operator] "Operator `|` is unsupported between objects of type `None` and `None`"
120+
```
121+
122+
When constructing something non-sensical like `int | 1`, we could ideally emit a diagnostic for the
123+
expression itself, as it leads to a `TypeError` at runtime. No other type checker supports this, so
124+
for now we only emit an error when it is used in a type expression:
125+
126+
```py
127+
IntOrOne = int | 1
128+
129+
# error: [invalid-type-form] "Variable of type `Literal[1]` is not allowed in a type expression"
130+
def _(int_or_one: IntOrOne):
131+
reveal_type(int_or_one) # revealed: Unknown
132+
```
133+
134+
## Generic types
135+
136+
Implicit type aliases can also refer to generic types:
137+
138+
```py
139+
from typing_extensions import TypeVar
140+
141+
T = TypeVar("T")
142+
143+
MyList = list[T]
144+
145+
def _(my_list: MyList[int]):
146+
# TODO: This should be `list[int]`
147+
reveal_type(my_list) # revealed: @Todo(unknown type subscript)
148+
149+
ListOrTuple = list[T] | tuple[T, ...]
150+
151+
def _(list_or_tuple: ListOrTuple[int]):
152+
reveal_type(list_or_tuple) # revealed: @Todo(Generic specialization of types.UnionType)
153+
```
154+
155+
## Stringified annotations?
156+
157+
From the [typing spec on type aliases](https://typing.python.org/en/latest/spec/aliases.html):
158+
159+
> Type aliases may be as complex as type hints in annotations – anything that is acceptable as a
160+
> type hint is acceptable in a type alias
161+
162+
However, no other type checker seems to support stringified annotations in implicit type aliases. We
163+
currently also do not support them:
164+
165+
```py
166+
AliasForStr = "str"
167+
168+
# error: [invalid-type-form] "Variable of type `Literal["str"]` is not allowed in a type expression"
169+
def _(s: AliasForStr):
170+
reveal_type(s) # revealed: Unknown
171+
172+
IntOrStr = int | "str"
173+
174+
# error: [invalid-type-form] "Variable of type `Literal["str"]` is not allowed in a type expression"
175+
def _(int_or_str: IntOrStr):
176+
reveal_type(int_or_str) # revealed: Unknown
177+
```
178+
179+
We *do* support stringified annotations if they appear in a position where a type expression is
180+
syntactically expected:
181+
182+
```py
183+
ListOfInts = list["int"]
184+
185+
def _(list_of_ints: ListOfInts):
186+
reveal_type(list_of_ints) # revealed: list[int]
187+
```
188+
20189
## Recursive
21190

22191
### Old union syntax

crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,11 @@ def _(flag: bool):
146146
def _(flag: bool):
147147
x = 1 if flag else "a"
148148

149-
# TODO: this should cause us to emit a diagnostic during
150-
# type checking
149+
# error: [invalid-argument-type] "Argument to function `isinstance` is incorrect: Expected `type | UnionType | tuple[Unknown, ...]`, found `Literal["a"]"
151150
if isinstance(x, "a"):
152151
reveal_type(x) # revealed: Literal[1, "a"]
153152

154-
# TODO: this should cause us to emit a diagnostic during
155-
# type checking
153+
# error: [invalid-argument-type] "Argument to function `isinstance` is incorrect: Expected `type | UnionType | tuple[Unknown, ...]`, found `Literal["int"]"
156154
if isinstance(x, "int"):
157155
reveal_type(x) # revealed: Literal[1, "a"]
158156
```

crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,7 @@ def flag() -> bool:
214214

215215
t = int if flag() else str
216216

217-
# TODO: this should cause us to emit a diagnostic during
218-
# type checking
217+
# error: [invalid-argument-type] "Argument to function `issubclass` is incorrect: Expected `type | UnionType | tuple[Unknown, ...]`, found `Literal["str"]"
219218
if issubclass(t, "str"):
220219
reveal_type(t) # revealed: <class 'int'> | <class 'str'>
221220

crates/ty_python_semantic/src/types.rs

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -817,13 +817,11 @@ impl<'db> Type<'db> {
817817
}
818818

819819
fn is_none(&self, db: &'db dyn Db) -> bool {
820-
self.as_nominal_instance()
821-
.is_some_and(|instance| instance.has_known_class(db, KnownClass::NoneType))
820+
self.is_instance_of(db, KnownClass::NoneType)
822821
}
823822

824823
fn is_bool(&self, db: &'db dyn Db) -> bool {
825-
self.as_nominal_instance()
826-
.is_some_and(|instance| instance.has_known_class(db, KnownClass::Bool))
824+
self.is_instance_of(db, KnownClass::Bool)
827825
}
828826

829827
fn is_enum(&self, db: &'db dyn Db) -> bool {
@@ -857,8 +855,7 @@ impl<'db> Type<'db> {
857855
}
858856

859857
pub(crate) fn is_notimplemented(&self, db: &'db dyn Db) -> bool {
860-
self.as_nominal_instance()
861-
.is_some_and(|instance| instance.has_known_class(db, KnownClass::NotImplementedType))
858+
self.is_instance_of(db, KnownClass::NotImplementedType)
862859
}
863860

864861
pub(crate) const fn is_todo(&self) -> bool {
@@ -6404,6 +6401,17 @@ impl<'db> Type<'db> {
64046401
invalid_expressions: smallvec::smallvec_inline![InvalidTypeExpression::Generic],
64056402
fallback_type: Type::unknown(),
64066403
}),
6404+
KnownInstanceType::UnionType(union_type) => {
6405+
let mut builder = UnionBuilder::new(db);
6406+
for element in union_type.elements(db) {
6407+
builder = builder.add(element.in_type_expression(
6408+
db,
6409+
scope_id,
6410+
typevar_binding_context,
6411+
)?);
6412+
}
6413+
Ok(builder.build())
6414+
}
64076415
},
64086416

64096417
Type::SpecialForm(special_form) => match special_form {
@@ -6572,9 +6580,6 @@ impl<'db> Type<'db> {
65726580
Some(KnownClass::GenericAlias) => Ok(todo_type!(
65736581
"Support for `typing.GenericAlias` instances in type expressions"
65746582
)),
6575-
Some(KnownClass::UnionType) => Ok(todo_type!(
6576-
"Support for `types.UnionType` instances in type expressions"
6577-
)),
65786583
_ => Err(InvalidTypeExpressionError {
65796584
invalid_expressions: smallvec::smallvec_inline![
65806585
InvalidTypeExpression::InvalidType(*self, scope_id)
@@ -7614,6 +7619,10 @@ pub enum KnownInstanceType<'db> {
76147619
/// A constraint set, which is exposed in mdtests as an instance of
76157620
/// `ty_extensions.ConstraintSet`.
76167621
ConstraintSet(TrackedConstraintSet<'db>),
7622+
7623+
/// A single instance of `types.UnionType`, which stores the left- and
7624+
/// right-hand sides of a PEP 604 union.
7625+
UnionType(UnionTypeInstance<'db>),
76177626
}
76187627

76197628
fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
@@ -7640,6 +7649,11 @@ fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
76407649
visitor.visit_type(db, default_ty);
76417650
}
76427651
}
7652+
KnownInstanceType::UnionType(union_type) => {
7653+
for element in union_type.elements(db) {
7654+
visitor.visit_type(db, *element);
7655+
}
7656+
}
76437657
}
76447658
}
76457659

@@ -7676,6 +7690,7 @@ impl<'db> KnownInstanceType<'db> {
76767690
// Nothing to normalize
76777691
Self::ConstraintSet(set)
76787692
}
7693+
Self::UnionType(union_type) => Self::UnionType(union_type.normalized_impl(db, visitor)),
76797694
}
76807695
}
76817696

@@ -7690,6 +7705,7 @@ impl<'db> KnownInstanceType<'db> {
76907705
Self::Deprecated(_) => KnownClass::Deprecated,
76917706
Self::Field(_) => KnownClass::Field,
76927707
Self::ConstraintSet(_) => KnownClass::ConstraintSet,
7708+
Self::UnionType(_) => KnownClass::UnionType,
76937709
}
76947710
}
76957711

@@ -7763,6 +7779,7 @@ impl<'db> KnownInstanceType<'db> {
77637779
constraints.display(self.db)
77647780
)
77657781
}
7782+
KnownInstanceType::UnionType(_) => f.write_str("UnionType"),
77667783
}
77677784
}
77687785
}
@@ -8882,6 +8899,39 @@ impl<'db> TypeVarBoundOrConstraints<'db> {
88828899
}
88838900
}
88848901

8902+
/// An instance of `types.UnionType`.
8903+
///
8904+
/// # Ordering
8905+
/// Ordering is based on the context's salsa-assigned id and not on its values.
8906+
/// The id may change between runs, or when the context was garbage collected and recreated.
8907+
#[salsa::interned(debug)]
8908+
#[derive(PartialOrd, Ord)]
8909+
pub struct UnionTypeInstance<'db> {
8910+
#[returns(deref)]
8911+
elements: Box<[Type<'db>; 2]>,
8912+
}
8913+
8914+
impl get_size2::GetSize for UnionTypeInstance<'_> {}
8915+
8916+
impl<'db> UnionTypeInstance<'db> {
8917+
pub(crate) fn from_elements(
8918+
db: &'db dyn Db,
8919+
left: Type<'db>,
8920+
right: Type<'db>,
8921+
) -> UnionTypeInstance<'db> {
8922+
UnionTypeInstance::new(db, Box::new([left, right]))
8923+
}
8924+
8925+
pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self {
8926+
let elements = self.elements(db);
8927+
UnionTypeInstance::from_elements(
8928+
db,
8929+
elements[0].normalized_impl(db, visitor),
8930+
elements[1].normalized_impl(db, visitor),
8931+
)
8932+
}
8933+
}
8934+
88858935
/// Error returned if a type is not awaitable.
88868936
#[derive(Debug)]
88878937
enum AwaitError<'db> {

crates/ty_python_semantic/src/types/class.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,9 +1307,7 @@ impl<'db> Field<'db> {
13071307
/// Returns true if this field is a `dataclasses.KW_ONLY` sentinel.
13081308
/// <https://docs.python.org/3/library/dataclasses.html#dataclasses.KW_ONLY>
13091309
pub(crate) fn is_kw_only_sentinel(&self, db: &'db dyn Db) -> bool {
1310-
self.declared_ty
1311-
.as_nominal_instance()
1312-
.is_some_and(|instance| instance.has_known_class(db, KnownClass::KwOnly))
1310+
self.declared_ty.is_instance_of(db, KnownClass::KwOnly)
13131311
}
13141312
}
13151313

crates/ty_python_semantic/src/types/class_base.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ impl<'db> ClassBase<'db> {
170170
| KnownInstanceType::TypeVar(_)
171171
| KnownInstanceType::Deprecated(_)
172172
| KnownInstanceType::Field(_)
173-
| KnownInstanceType::ConstraintSet(_) => None,
173+
| KnownInstanceType::ConstraintSet(_)
174+
| KnownInstanceType::UnionType(_) => None,
174175
},
175176

176177
Type::SpecialForm(special_form) => match special_form {

0 commit comments

Comments
 (0)