[ty] Fix false positive for bounded type parameters with NewType (#22542)

Fixes https://github.com/astral-sh/ty/issues/2467

When calling a method on an instance of a generic class with bounded
type parameters (e.g., `C[T: K]` where `K` is a NewType), ty was
incorrectly reporting: "Argument type `C[K]` does not satisfy upper
bound `C[T@C]` of type variable `Self`"

The issue was introduced by PR #22105, which moved the catch-all case
for NewType assignments that falls back to the concrete base type. This
case was moved before the TypeVar handling cases, so when checking `K <:
T@C` (where K is a NewType and T@C is a TypeVar with upper bound K):

1. The NewType fallback matched first
2. It delegated to `int` (K's concrete base type)
3. Then checked `int <: T@C`, which checks if `int` satisfies bound `K`
4. But `int` is not assignable to `K` (NewTypes are distinct from their
bases)

The fix moves the NewType fallback case after the TypeVar cases, so
TypeVar handling takes precedence. Now when checking `K <: T@C`, we use
the TypeVar case at line 828 which returns `false` for non-inferable
typevars - but this is correct because the *other* direction (`T@C <:
K`) passes, and for the overall specialization comparison both
directions are checked.
This commit is contained in:
Carl Meyer
2026-01-12 17:23:31 -08:00
committed by GitHub
parent 3ae4db3ccd
commit 99beabdde8
2 changed files with 49 additions and 11 deletions

View File

@@ -456,6 +456,40 @@ reveal_type(int_container) # revealed: Container[int]
reveal_type(int_container.set_value(1)) # revealed: Container[int]
```
## Generic class with bounded type variable
This is a regression test for <https://github.com/astral-sh/ty/issues/2467>.
Calling a method on a generic class instance should work when the type parameter is specialized with
a type that satisfies a bound.
```py
from typing import NewType
class Base: ...
class C[T: Base]:
x: T
def g(self) -> None:
pass
# Calling a method on a specialized instance should not produce an error
C[Base]().g()
# Test with a NewType bound
K = NewType("K", int)
class D[T: K]:
x: T
def h(self) -> None:
pass
# Calling a method on a specialized instance should not produce an error
D[K]().h()
```
## Protocols
TODO: <https://typing.python.org/en/latest/spec/generics.html#use-in-protocols>

View File

@@ -715,17 +715,6 @@ impl<'db> Type<'db> {
}
})
}
// All other `NewType` assignments fall back to the concrete base type.
(Type::NewTypeInstance(self_newtype), _) => {
self_newtype.concrete_base_type(db).has_relation_to_impl(
db,
target,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
(Type::Union(union), _) => union.elements(db).iter().when_all(db, |&elem_ty| {
elem_ty.has_relation_to_impl(
@@ -869,6 +858,21 @@ impl<'db> Type<'db> {
ConstraintSet::from(false)
}
// All other `NewType` assignments fall back to the concrete base type.
// This case must come after the TypeVar cases above, so that when checking
// `NewType <: TypeVar`, we use the TypeVar handling rather than falling back
// to the NewType's concrete base type.
(Type::NewTypeInstance(self_newtype), _) => {
self_newtype.concrete_base_type(db).has_relation_to_impl(
db,
target,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
// Note that the definition of `Type::AlwaysFalsy` depends on the return value of `__bool__`.
// If `__bool__` always returns True or False, it can be treated as a subtype of `AlwaysTruthy` or `AlwaysFalsy`, respectively.
(left, Type::AlwaysFalsy) => ConstraintSet::from(left.bool(db).is_always_false()),