From f97da18267c513099134dca4ec66f62f949bab32 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 6 Jan 2026 13:10:51 -0800 Subject: [PATCH] [ty] improve typevar solving from constraint sets (#22411) ## Summary Fixes https://github.com/astral-sh/ty/issues/2292 When solving a bounded typevar, we preferred the upper bound over the actual type seen in the call. This change fixes that. ## Test Plan Added mdtest, existing tests pass. --- .../mdtest/generics/legacy/functions.md | 29 +++++++++++++++++++ .../ty_python_semantic/src/types/generics.rs | 13 +++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md index 7f786b8765..c309a76cd9 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md @@ -752,3 +752,32 @@ reveal_type(x) # revealed: list[Sub] y: list[Sub] = f2(Sub()) reveal_type(y) # revealed: list[Sub] ``` + +## Bounded TypeVar with callable parameter + +When a bounded TypeVar appears in a `Callable` parameter's return type, the inferred type should be +the actual type from the call, not the TypeVar's upper bound. + +See: + +```py +from typing import Callable, TypeVar + +class Base: + pass + +class Derived(Base): + attr: int + +T = TypeVar("T", bound=Base) + +def takes_factory(factory: Callable[[], T]) -> T: + return factory() + +# Passing a class as a factory: should infer Derived, not Base +result = takes_factory(Derived) +reveal_type(result) # revealed: Derived + +# Accessing an attribute that only exists on Derived should work +print(result.attr) # No error +``` diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index a14d595470..3b772842d3 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1662,14 +1662,17 @@ impl<'db> SpecializationBuilder<'db> { for (bound_typevar, bounds) in mappings.drain() { let variance = formal.variance_of(self.db, bound_typevar); - let upper = IntersectionType::from_elements(self.db, bounds.upper); - if !upper.is_object() { - self.add_type_mapping(bound_typevar, upper, variance, &mut f); - continue; - } + // Prefer the lower bound (often the concrete actual type seen) over the + // upper bound (which may include TypeVar bounds/constraints). The upper bound + // should only be used as a fallback when no concrete type was inferred. let lower = UnionType::from_elements(self.db, bounds.lower); if !lower.is_never() { self.add_type_mapping(bound_typevar, lower, variance, &mut f); + continue; + } + let upper = IntersectionType::from_elements(self.db, bounds.upper); + if !upper.is_object() { + self.add_type_mapping(bound_typevar, upper, variance, &mut f); } } }