From e15f88ff212577b0cd62c003b9ce7add4218f49c Mon Sep 17 00:00:00 2001 From: Dex Devlon <51504045+bxff@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:30:35 +0530 Subject: [PATCH] [ty] Fix contravariant type variable bound checking in specialization inference (#22488) ## Summary Correctly handle upper bounds for contravariant type variables during specialization inference. Previously, the type checker incorrectly applied covariant subtyping rules, requiring the actual type to directly satisfy the bound rather than checking for a valid intersection. In contravariant positions, subtyping relationships are inverted. The bug caused valid code like `f(x: Contra[str])` where `f` expects `Contra[T: int]` to be incorrectly rejected, when it should solve `T` to `Never` (the intersection of `int` and `str`). Closes https://github.com/astral-sh/ty/issues/2427 ## Details - Added `is_contravariant()` helper to `TypeVarVariance` in `variance.rs` - Updated `SpecializationBuilder::infer_map_impl` in `generics.rs` to treat bounds and constraints differently based on variance: * Skip immediate `ty <: bound` check for contravariant upper bounds * Flip constraint check to `constraint <: ty` for contravariant positions - Added test case for bounded contravariant type variables in `variance.md` - All 308 mdtest cases pass & 150 ty_python_semantic unit tests pass --------- Co-authored-by: Douglas Creager --- .../mdtest/generics/legacy/variance.md | 21 ++++++++++++++ .../mdtest/generics/pep695/variance.md | 17 +++++++++++ .../ty_python_semantic/src/types/generics.rs | 29 ++++++++++++++++--- .../ty_python_semantic/src/types/variance.rs | 7 +++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md index 4c2a7032ab..732a2a878a 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variance.md @@ -171,6 +171,27 @@ static_assert(not is_equivalent_to(D[Any], C[Any])) static_assert(not is_equivalent_to(D[Any], C[Unknown])) ``` +## Bounded typevars in contravariant positions + +When a bounded typevar appears in a contravariant position, the actual type doesn't need to satisfy +the bound directly. The typevar can be solved to the intersection of the actual type and the bound +(e.g., `Never` when disjoint). + +```py +from typing import Generic, TypeVar + +T = TypeVar("T", contravariant=True) +T_int = TypeVar("T_int", bound=int) + +class Contra(Generic[T]): ... + +def f(x: Contra[T_int]) -> T_int: + raise NotImplementedError + +def _(x: Contra[str]): + reveal_type(f(x)) # revealed: Never +``` + ## Invariance With an invariant typevar, only equivalent specializations of the generic class are subtypes of or diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md index 77a41caa00..a04118988e 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variance.md @@ -172,6 +172,23 @@ static_assert(not is_equivalent_to(D[Any], C[Any])) static_assert(not is_equivalent_to(D[Any], C[Unknown])) ``` +## Bounded typevars in contravariant positions + +When a bounded typevar appears in a contravariant position, the actual type doesn't need to satisfy +the bound directly. The typevar can be solved to the intersection of the actual type and the bound +(e.g., `Never` when disjoint). + +```py +class Contra[T]: + def append(self, x: T): ... + +def f[T: int](x: Contra[T]) -> T: + raise NotImplementedError + +def _(x: Contra[str]): + reveal_type(f(x)) # revealed: Never +``` + ## Invariance With an invariant typevar, only equivalent specializations of the generic class are subtypes of or diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index b92330f729..b467f08bbe 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1878,6 +1878,21 @@ impl<'db> SpecializationBuilder<'db> { { match bound_typevar.typevar(self.db).bound_or_constraints(self.db) { Some(TypeVarBoundOrConstraints::UpperBound(bound)) => { + if polarity.is_contravariant() { + // In a contravariant position, the formal type variable is a subtype of + // the actual type (`T <: ty`). Since we also have the upper bound + // constraint `T <: bound`, we just need to ensure that the intersection + // of `ty` and `bound` is non-empty. Since `Never` is always a valid + // intersection if the types are disjoint, we don't need to perform any + // check here. + self.add_type_mapping( + bound_typevar, + IntersectionType::from_elements(self.db, [bound, ty]), + polarity, + f, + ); + return Ok(()); + } if !ty .when_assignable_to(self.db, bound, self.inferable) .is_always_satisfied(self.db) @@ -1899,10 +1914,16 @@ impl<'db> SpecializationBuilder<'db> { } for constraint in constraints.elements(self.db) { - if ty - .when_assignable_to(self.db, *constraint, self.inferable) - .is_always_satisfied(self.db) - { + let is_satisfied = if polarity.is_contravariant() { + constraint + .when_assignable_to(self.db, ty, self.inferable) + .is_always_satisfied(self.db) + } else { + ty.when_assignable_to(self.db, *constraint, self.inferable) + .is_always_satisfied(self.db) + }; + + if is_satisfied { self.add_type_mapping(bound_typevar, *constraint, polarity, f); return Ok(()); } diff --git a/crates/ty_python_semantic/src/types/variance.rs b/crates/ty_python_semantic/src/types/variance.rs index 5ec1d5a8ff..aa32bd6f17 100644 --- a/crates/ty_python_semantic/src/types/variance.rs +++ b/crates/ty_python_semantic/src/types/variance.rs @@ -92,6 +92,13 @@ impl TypeVarVariance { TypeVarVariance::Covariant | TypeVarVariance::Bivariant ) } + + pub(crate) const fn is_contravariant(self) -> bool { + matches!( + self, + TypeVarVariance::Contravariant | TypeVarVariance::Bivariant + ) + } } impl std::iter::FromIterator for TypeVarVariance {