[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 <dcreager@dcreager.net>
This commit is contained in:
Dex Devlon
2026-01-12 15:30:35 +05:30
committed by GitHub
parent a559275c3e
commit e15f88ff21
4 changed files with 70 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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(());
}

View File

@@ -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<Self> for TypeVarVariance {