From dbeba3c22b49019af2e045321f4cbd0d53dd5009 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 20 Dec 2025 14:13:10 -0500 Subject: [PATCH] [ty] Fix iteration over intersections with TypeVars whose bounds contain non-iterable types --- .../resources/mdtest/loops/for.md | 21 ++++++++ crates/ty_python_semantic/src/types.rs | 51 +++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/loops/for.md b/crates/ty_python_semantic/resources/mdtest/loops/for.md index 3916fa884a..9861ac6285 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/for.md @@ -951,3 +951,24 @@ for x in Bar: # TODO: should reveal `Any` reveal_type(x) # revealed: Unknown ``` + +## Iterating over an intersection with a TypeVar whose bound is a union + +When a TypeVar has a union bound where some elements are iterable and some are not, and the TypeVar +is intersected with an iterable type (e.g., via `isinstance`), the iteration should use the iterable +parts of the TypeVar's bound. + +```toml +[environment] +python-version = "3.12" +``` + +```py +def f[T: tuple[int, ...] | int](x: T): + if isinstance(x, tuple): + reveal_type(x) # revealed: T@f & tuple[object, ...] + for item in x: + # The TypeVar T is constrained to tuple[int, ...] by the isinstance check, + # so iterating should give `int`, not `object`. + reveal_type(item) # revealed: int +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index da46f40709..71ba08dc02 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -6722,13 +6722,54 @@ impl<'db> Type<'db> { // the resulting element types. Negative elements don't affect iteration. // We only fail if all elements fail to iterate; as long as at least one // element can be iterated over, we can produce a result. + // + // For TypeVars with union bounds where some union elements are not iterable, + // we iterate the iterable parts of the bound. This is sound because the + // intersection constrains the TypeVar to only the iterable parts. + // For example, for `T & tuple[object, ...]` where `T: tuple[int, ...] | int`, + // iterating should give `int` (from the `tuple[int, ...]` part of T's bound), + // not `object` (from ignoring T entirely). + let try_iterate_element = + |element: Type<'db>| -> Option>> { + // First try normal iteration + if let Ok(spec) = + element.try_iterate_with_mode(db, EvaluationMode::Sync) + { + return Some(spec); + } + + // If that fails and the element is a TypeVar with a union bound, + // try to iterate the iterable parts of the union. + if let Type::TypeVar(tvar) = element { + if let Some(TypeVarBoundOrConstraints::UpperBound(Type::Union( + union, + ))) = tvar.typevar(db).bound_or_constraints(db) + { + // Collect iteration specs for all iterable union elements. + let mut iterable_specs = union.elements(db).iter().filter_map( + |elem| { + elem.try_iterate_with_mode(db, EvaluationMode::Sync) + .ok() + }, + ); + + // If any union elements are iterable, union their specs. + if let Some(first) = iterable_specs.next() { + let mut builder = TupleSpecBuilder::from(&*first); + for spec in iterable_specs { + builder = builder.union(db, &spec); + } + return Some(Cow::Owned(builder.build())); + } + } + } + + None + }; + let mut specs_iter = intersection .positive_elements_or_object(db) - .filter_map(|element| { - element - .try_iterate_with_mode(db, EvaluationMode::Sync) - .ok() - }); + .filter_map(try_iterate_element); let first_spec = specs_iter.next()?; let mut builder = TupleSpecBuilder::from(&*first_spec); for spec in specs_iter {