From bca1de0bad5725cdfcc1201e3ce2de61c7644907 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 6 Oct 2026 16:52:37 -0400 Subject: [PATCH] [ty] Preserve positive aliases when rebuilding mapped intersections --- .../mdtest/generics/legacy/aliases.md | 37 +++++++++++++++++++ .../mdtest/generics/pep695/aliases.md | 35 ++++++++++++++++++ .../src/types/set_theoretic.rs | 3 +- .../src/types/set_theoretic/builder.rs | 31 +++++++++++----- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/aliases.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/aliases.md index 51c9222d1f6556..a422546fe9cce7 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/aliases.md @@ -66,3 +66,40 @@ A = TypeAliasType("A", "list[Without[object, A] | A]") def f(x: A): assert_type(x[0], object) ``` + +## Recursive tuples intersected with a protocol + +Specialization can put a recursive tuple alias inside a positive intersection. The tuple and its +elements retain that alias, so they can be inspected without repeatedly unfolding the tuple. + +```py +from collections.abc import Iterator +from typing import Any, Protocol, TypeVar +from typing_extensions import TypeAliasType, assert_type +from ty_extensions import Intersection + +class Items(Protocol): + def __iter__(self) -> Iterator[Any]: ... + +T = TypeVar("T") +WithItems = TypeAliasType("WithItems", Intersection[Items, T], type_params=(T,)) +A = TypeAliasType("A", "tuple[WithItems[A], ...]") + +def f(x: A): + reveal_type(x) # revealed: tuple[WithItems[A], ...] + assert_type(x[0], WithItems[A]) + y: int = x[0] # error: [invalid-assignment] +``` + +The standard `Iterable` protocol exercises the same behavior with its typeshed definition. + +```py +from collections.abc import Iterable + +WithIterable = TypeAliasType("WithIterable", Intersection[Iterable[Any], T], type_params=(T,)) +B = TypeAliasType("B", "tuple[WithIterable[B], ...]") + +def g(x: B): + reveal_type(x) # revealed: tuple[WithIterable[B], ...] + assert_type(x[0], WithIterable[B]) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md index cc076be9a05fb5..7a7826013bc323 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md @@ -534,6 +534,41 @@ def f(x: A): assert_type(x[0], object) ``` +## Recursive tuples intersected with a protocol + +Specialization can put a recursive tuple alias inside a positive intersection. The tuple and its +elements retain that alias, so they can be inspected without repeatedly unfolding the tuple. + +```py +from collections.abc import Iterator +from typing import Any, Protocol, assert_type +from ty_extensions import Intersection + +class Items(Protocol): + def __iter__(self) -> Iterator[Any]: ... + +type WithItems[T] = Intersection[Items, T] +type A = tuple[WithItems[A], ...] + +def f(x: A): + reveal_type(x) # revealed: tuple[WithItems[A], ...] + assert_type(x[0], WithItems[A]) + y: int = x[0] # error: [invalid-assignment] +``` + +The standard `Iterable` protocol exercises the same behavior with its typeshed definition. + +```py +from collections.abc import Iterable + +type WithIterable[T] = Intersection[Iterable[Any], T] +type B = tuple[WithIterable[B], ...] + +def g(x: B): + reveal_type(x) # revealed: tuple[WithIterable[B], ...] + assert_type(x[0], WithIterable[B]) +``` + ## Solving generics with type alias parameters A generic function parameter annotated with a PEP 695 type alias that contains a type variable diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index 1ae96635d73e7d..7c38e001180b61 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -1135,8 +1135,7 @@ impl<'db> IntersectionType<'db> { } Type::Intersection(IntersectionType::new(db, positive, negative)) } else { - let mut builder = - IntersectionBuilder::new(db, visitor.env).preserve_negated_aliases(true); + let mut builder = IntersectionBuilder::new(db, visitor.env).preserve_aliases(true); for positive in self.positive(db) { builder.add_positive_in_place(positive.apply_type_mapping_impl( db, diff --git a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs index e4cc05ab4daff9..c2f283f9fa9754 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -1316,7 +1316,7 @@ pub(crate) struct IntersectionBuilder<'db> { // One disjunction does not multiply alternatives. Only subsequent distributions consume // the bounded constructor's budget, after impossible and redundant branches are removed. has_disjunction: bool, - preserve_negated_aliases: bool, + preserve_aliases: bool, } impl<'db> IntersectionBuilder<'db> { @@ -1326,17 +1326,19 @@ impl<'db> IntersectionBuilder<'db> { env: env.clone(), intersections: vec![InnerIntersectionBuilder::default()], has_disjunction: false, - preserve_negated_aliases: false, + preserve_aliases: false, } } - /// Retain aliases under negation when their values need no further normalization. + /// Retain aliases when rebuilding mapped intersections. /// - /// Rebuilding a mapped intersection must not unfold a recursive alias on every visit. - /// Aliases to set-theoretic types, `object`, `Never`, and other types with special - /// negation rules still need to be expanded. - pub(crate) fn preserve_negated_aliases(mut self, preserve: bool) -> Self { - self.preserve_negated_aliases = preserve; + /// Unfolding aliases after their mapping has completed can repeatedly insert a recursive + /// type into itself. Positive elements retain aliases to non-`object` nominal instances. + /// Negative elements retain aliases whose values need no special negation rules. + /// In both cases we still expand aliases to set operations or `object` so their + /// simplifications remain visible. + pub(crate) fn preserve_aliases(mut self, preserve: bool) -> Self { + self.preserve_aliases = preserve; self } @@ -1484,7 +1486,16 @@ impl<'db> IntersectionBuilder<'db> { } seen_aliases.push(ty); let value_type = ty.resolve_type_alias(db); - self.add_positive_impl::(value_type, seen_aliases)?; + if self.preserve_aliases + && matches!(ty, Type::TypeAlias(_)) + && matches!(value_type, Type::NominalInstance(instance) if !instance.is_object()) + { + for inner in &mut self.intersections { + inner.add_positive(db, &self.env, ty); + } + } else { + self.add_positive_impl::(value_type, seen_aliases)?; + } } Type::Union(union) => { // Distribute ourself over this union: for each union element, clone ourself and @@ -1542,7 +1553,7 @@ impl<'db> IntersectionBuilder<'db> { /// of its resolved value. Alias chains and set-theoretic types need expansion here; /// the remaining exclusions have special rules in `InnerIntersectionBuilder::add_negative`. fn should_preserve_negated_alias(&self, ty: Type<'db>, value_type: Type<'db>) -> bool { - if !self.preserve_negated_aliases || !matches!(ty, Type::TypeAlias(_)) { + if !self.preserve_aliases || !matches!(ty, Type::TypeAlias(_)) { return false; }