Skip to content

Commit a8d1d86

Browse files
authored
[ty] Fix narrowing PEP 695 type aliases (#22894)
A number of our narrowing cases didn't account for PEP 695 type aliases. The narrowing logic now correctly resolves the type alias and narrows accordingly. Fixes astral-sh/ty#2645
1 parent 500fed6 commit a8d1d86

4 files changed

Lines changed: 306 additions & 34 deletions

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,57 @@ def _(x: tuple[Literal["tag1"], A] | tuple[Literal["tag2"], B] | list[int]):
374374
reveal_type(x) # revealed: tuple[Literal["tag1"], A] | list[int]
375375
```
376376

377+
### PEP 695 type aliases
378+
379+
Tuple narrowing also works when the union is defined via a PEP 695 type alias:
380+
381+
```toml
382+
[environment]
383+
python-version = "3.12"
384+
```
385+
386+
```py
387+
from typing import Literal
388+
389+
class A: ...
390+
class B: ...
391+
392+
type TaggedTuple = tuple[Literal["a"], A] | tuple[Literal["b"], B]
393+
394+
def test_equality_narrowing(x: TaggedTuple):
395+
if x[0] == "a":
396+
reveal_type(x) # revealed: tuple[Literal["a"], A]
397+
else:
398+
reveal_type(x) # revealed: tuple[Literal["b"], B]
399+
400+
type NullableTuple = tuple[int, int] | tuple[None, None]
401+
402+
def test_is_narrowing(t: NullableTuple):
403+
if t[0] is not None:
404+
reveal_type(t) # revealed: tuple[int, int]
405+
else:
406+
reveal_type(t) # revealed: tuple[None, None]
407+
408+
# Nested type aliases (an alias referring to another alias) also work:
409+
type InnerTagged = tuple[Literal["a"], A] | tuple[Literal["b"], B]
410+
type OuterTagged = InnerTagged
411+
412+
def test_nested_equality_narrowing(x: OuterTagged):
413+
if x[0] == "a":
414+
reveal_type(x) # revealed: tuple[Literal["a"], A]
415+
else:
416+
reveal_type(x) # revealed: tuple[Literal["b"], B]
417+
418+
type InnerNullable = tuple[int, int] | tuple[None, None]
419+
type OuterNullable = InnerNullable
420+
421+
def test_nested_is_narrowing(t: OuterNullable):
422+
if t[0] is not None:
423+
reveal_type(t) # revealed: tuple[int, int]
424+
else:
425+
reveal_type(t) # revealed: tuple[None, None]
426+
```
427+
377428
### String subscript
378429

379430
```py

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2198,6 +2198,51 @@ class WackyInt(int):
21982198
_: NonLiteralTD = {"tag": WackyInt(99)} # allowed
21992199
```
22002200

2201+
Intersections containing a TypedDict with literal fields can be narrowed with equality checks. Since
2202+
`Foo` requires `tag == "foo"`, the else branch is `Never`:
2203+
2204+
```py
2205+
from ty_extensions import Intersection
2206+
from typing import Any
2207+
2208+
def _(x: Intersection[Foo, Any]):
2209+
if x["tag"] == "foo":
2210+
reveal_type(x) # revealed: Foo & Any
2211+
else:
2212+
reveal_type(x) # revealed: Never
2213+
```
2214+
2215+
But intersections with non-literal fields cannot be narrowed:
2216+
2217+
```py
2218+
from ty_extensions import Intersection
2219+
from typing import Any
2220+
2221+
def _(x: Intersection[NonLiteralTD, Any]):
2222+
if x["tag"] == 42:
2223+
reveal_type(x) # revealed: NonLiteralTD & Any
2224+
else:
2225+
reveal_type(x) # revealed: NonLiteralTD & Any
2226+
```
2227+
2228+
This is especially important when the field type is disjoint from the comparison literal. Even
2229+
though `str` and `int` are disjoint, we can't narrow here because a `str` subclass could override
2230+
`__eq__` to return `True`. Without proper handling, this would wrongly narrow to `Never`:
2231+
2232+
```py
2233+
from ty_extensions import Intersection
2234+
from typing import Any
2235+
2236+
class StrTagTD(TypedDict):
2237+
tag: str
2238+
2239+
def _(x: Intersection[StrTagTD, Any]):
2240+
if x["tag"] == 42:
2241+
reveal_type(x) # revealed: StrTagTD & Any
2242+
else:
2243+
reveal_type(x) # revealed: StrTagTD & Any
2244+
```
2245+
22012246
We can still narrow `Literal` tags even when non-`TypedDict` types are present in the union:
22022247

22032248
```py
@@ -2365,6 +2410,88 @@ def match_with_dict(u: Foo | Bar | dict):
23652410
reveal_type(u) # revealed: Foo | (dict[Unknown, Unknown] & ~<TypedDict with items 'tag'>)
23662411
```
23672412

2413+
## Narrowing tagged unions of `TypedDict`s from PEP 695 type aliases
2414+
2415+
PEP 695 type aliases are transparently resolved when narrowing tagged unions:
2416+
2417+
```toml
2418+
[environment]
2419+
python-version = "3.12"
2420+
```
2421+
2422+
```py
2423+
from typing import TypedDict, Literal
2424+
2425+
class Foo(TypedDict):
2426+
tag: Literal["foo"]
2427+
2428+
class Bar(TypedDict):
2429+
tag: Literal["bar"]
2430+
2431+
type Thing = Foo | Bar
2432+
2433+
def test_if(x: Thing):
2434+
if x["tag"] == "foo":
2435+
reveal_type(x) # revealed: Foo
2436+
else:
2437+
reveal_type(x) # revealed: Bar
2438+
```
2439+
2440+
PEP 695 type aliases also work in `match` statements:
2441+
2442+
```py
2443+
def test_match(x: Thing):
2444+
match x["tag"]:
2445+
case "foo":
2446+
reveal_type(x) # revealed: Foo
2447+
case "bar":
2448+
reveal_type(x) # revealed: Bar
2449+
```
2450+
2451+
PEP 695 type aliases also work with `in`/`not in` narrowing:
2452+
2453+
```py
2454+
class Baz(TypedDict):
2455+
baz: int
2456+
2457+
type ThingWithBaz = Foo | Baz
2458+
2459+
def test_in(x: ThingWithBaz):
2460+
if "baz" not in x:
2461+
reveal_type(x) # revealed: Foo
2462+
else:
2463+
reveal_type(x) # revealed: Foo | Baz
2464+
```
2465+
2466+
Nested PEP 695 type aliases (an alias referring to another alias) also work:
2467+
2468+
```py
2469+
type Inner = Foo | Bar
2470+
type Outer = Inner
2471+
2472+
def test_nested_if(x: Outer):
2473+
if x["tag"] == "foo":
2474+
reveal_type(x) # revealed: Foo
2475+
else:
2476+
reveal_type(x) # revealed: Bar
2477+
2478+
def test_nested_match(x: Outer):
2479+
match x["tag"]:
2480+
case "foo":
2481+
reveal_type(x) # revealed: Foo
2482+
case "bar":
2483+
reveal_type(x) # revealed: Bar
2484+
2485+
type InnerWithBaz = Foo | Baz
2486+
type OuterWithBaz = InnerWithBaz
2487+
2488+
def test_nested_in(x: OuterWithBaz):
2489+
if "baz" not in x:
2490+
reveal_type(x) # revealed: Foo
2491+
else:
2492+
reveal_type(x) # revealed: Foo | Baz
2493+
```
2494+
23682495
## Only annotated declarations are allowed in the class body
23692496

23702497
<!-- snapshot-diagnostics -->

crates/ty_python_semantic/src/types.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,16 @@ impl<'db> Type<'db> {
12101210
}
12111211
}
12121212

1213+
/// If this type is a `Type::TypeAlias`, recursively resolves it to its
1214+
/// underlying value type. Otherwise, returns `self` unchanged.
1215+
pub(crate) fn resolve_type_alias(self, db: &'db dyn Db) -> Type<'db> {
1216+
let mut ty = self;
1217+
while let Type::TypeAlias(alias) = ty {
1218+
ty = alias.value_type(db);
1219+
}
1220+
ty
1221+
}
1222+
12131223
pub(crate) const fn as_dynamic(self) -> Option<DynamicType<'db>> {
12141224
match self {
12151225
Type::Dynamic(dynamic_type) => Some(dynamic_type),

0 commit comments

Comments
 (0)