From 99beabdde89651b72c3b297502e85c4efcf567f0 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 12 Jan 2026 17:23:31 -0800 Subject: [PATCH] [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. --- .../resources/mdtest/annotations/self.md | 34 +++++++++++++++++++ .../ty_python_semantic/src/types/relation.rs | 26 ++++++++------ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/self.md b/crates/ty_python_semantic/resources/mdtest/annotations/self.md index 72fa7acbc8..32fd3930cf 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/self.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/self.md @@ -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 . + +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: diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 45c30e5bef..515b5e36bf 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -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()),