From ce83cfcc4a9cc965bffd51cf526252e3c3bf00f1 Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Mon, 16 Mar 2026 16:55:38 -0700 Subject: [PATCH 1/3] [ty] make `Type::BoundMethod` include instances of same-named methods bound to a subclass Fixes https://github.com/astral-sh/ty/issues/2428. --- .../resources/mdtest/intersection_types.md | 53 +++++++++++++++++++ .../type_properties/is_disjoint_from.md | 34 ++++++++++++ .../type_properties/is_single_valued.md | 3 +- crates/ty_python_semantic/src/types.rs | 6 ++- .../ty_python_semantic/src/types/relation.rs | 16 +++++- 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/intersection_types.md b/crates/ty_python_semantic/resources/mdtest/intersection_types.md index dd63a7ccc697d..edfe868538067 100644 --- a/crates/ty_python_semantic/resources/mdtest/intersection_types.md +++ b/crates/ty_python_semantic/resources/mdtest/intersection_types.md @@ -1324,5 +1324,58 @@ def f(c: C): reveal_type(c.x) # revealed: ~AlwaysFalsy ``` +## Methods on intersections + +### The same method from a common base + +```py +from ty_extensions import Intersection + +class Base: + def f(self) -> int: + return 0 + +class X(Base): + pass + +class Y(Base): + pass + +def test(x: Intersection[X, Y]) -> None: + reveal_type(x.f) # revealed: (bound method X.f() -> int) & (bound method Y.f() -> int) + reveal_type(x.f()) # revealed: int + +# Example subclass that inhabits that intersection: +class Z(X, Y): + pass + +test(Z()) +``` + +### A method that could be compatible with a protocol (without violating Liskov) + +```py +from typing import Protocol +from ty_extensions import Intersection + +class Proto(Protocol): + def method(self) -> int: ... + +class X: + # This `method` isn't compatible with `Proto`, but a subclass could refine it... + def method(self) -> int | str: + return "hello" + +def test(x: Intersection[X, Proto]) -> None: + reveal_type(x.method()) # revealed: int + +# ...like this. This subclass inhabits the intersection above. +class Y(X): + def method(self) -> int: + return 42 + +test(Y()) +``` + [complement laws]: https://en.wikipedia.org/wiki/Complement_(set_theory) [de morgan's laws]: https://en.wikipedia.org/wiki/De_Morgan%27s_laws diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md index 1e9ffc79efa4c..fa317e287eea0 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md @@ -472,6 +472,40 @@ static_assert(not is_disjoint_from(TypeOf[f], FunctionType)) static_assert(not is_disjoint_from(TypeOf[f], object)) ``` +### Bound methods + +```py +from typing import final +from ty_extensions import TypeOf, is_disjoint_from, static_assert + +class A: + def foo(self) -> None: ... + def bar(self) -> None: ... + +class B: + def foo(self) -> None: ... + +# Bound methods with different names are disjoint. +static_assert(is_disjoint_from(TypeOf[A().foo], TypeOf[A().bar])) + +# Bound methods with the same name but different self types are not disjoint, because a subclass +# could inherit from both... +static_assert(not is_disjoint_from(TypeOf[A().foo], TypeOf[B().foo])) + +@final +class F1: + def foo(self) -> None: ... + +@final +class F2: + def foo(self) -> None: ... + +# ...unless one or both of those classes are `@final`. +static_assert(is_disjoint_from(TypeOf[A().foo], TypeOf[F2().foo])) +static_assert(is_disjoint_from(TypeOf[B().foo], TypeOf[F2().foo])) +static_assert(is_disjoint_from(TypeOf[F1().foo], TypeOf[F2().foo])) +``` + ### `AlwaysTruthy` and `AlwaysFalsy` ```py diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_single_valued.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_single_valued.md index 06922504753c7..698edbbe261f4 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_single_valued.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_single_valued.md @@ -47,7 +47,8 @@ static_assert(not is_single_valued(Callable[[int, str], None])) class A: def method(self): ... -static_assert(is_single_valued(TypeOf[A().method])) +# Binding the same method to different instances yields different objects: `[].sort != [].sort` +static_assert(not is_single_valued(TypeOf[A().method])) static_assert(is_single_valued(TypeOf[types.FunctionType.__get__])) static_assert(is_single_valued(TypeOf[A.method.__get__])) ``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index b32442e4a9b13..0c23204c94daa 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -2076,7 +2076,6 @@ impl<'db> Type<'db> { pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool { match self { Type::FunctionLiteral(..) - | Type::BoundMethod(_) | Type::WrapperDescriptor(_) | Type::KnownBoundMethod(_) | Type::ModuleLiteral(..) @@ -2131,6 +2130,11 @@ impl<'db> Type<'db> { false } + Type::BoundMethod(_) => { + // Binding the same method to different instances yields different objects: `[].sort != [].sort` + false + } + Type::TypeIs(type_is) => type_is.is_bound(db), Type::TypeGuard(type_guard) => type_guard.is_bound(db), diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 646b7a14df8f9..911c8239a2f21 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1877,7 +1877,6 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { ( // note `LiteralString` is not single-valued, but we handle the special case above left @ (Type::FunctionLiteral(..) - | Type::BoundMethod(..) | Type::KnownBoundMethod(..) | Type::WrapperDescriptor(..) | Type::ModuleLiteral(..) @@ -1885,7 +1884,6 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { | Type::SpecialForm(..) | Type::KnownInstance(..)), right @ (Type::FunctionLiteral(..) - | Type::BoundMethod(..) | Type::KnownBoundMethod(..) | Type::WrapperDescriptor(..) | Type::ModuleLiteral(..) @@ -2197,6 +2195,20 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { .negate(db, self.constraints) } + // A `BoundMethod` type includes instances of the same method bound to a + // subtype/subclass of the self type. + (Type::BoundMethod(a), Type::BoundMethod(b)) => { + if a.function(db).name(db) != b.function(db).name(db) { + // Two `BoundMethod`s have to have the same method name to have any possibility + // of overlap. + self.always() + } else { + // If the names match, then disjointness depends on whether the class types are + // disjoint. + self.check_type_pair(db, a.self_instance(db), b.self_instance(db)) + } + } + (Type::BoundMethod(_), other) | (other, Type::BoundMethod(_)) => { self.check_type_pair(db, KnownClass::MethodType.to_instance(db), other) } From e91c5eb4f388d81ad3b4c650fd75a3e9eb101e61 Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Wed, 25 Mar 2026 14:21:16 -0700 Subject: [PATCH 2/3] comment about Liskov violations --- .../ty_python_semantic/src/types/relation.rs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 911c8239a2f21..4a6cfd0e36aa5 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -2199,12 +2199,28 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { // subtype/subclass of the self type. (Type::BoundMethod(a), Type::BoundMethod(b)) => { if a.function(db).name(db) != b.function(db).name(db) { - // Two `BoundMethod`s have to have the same method name to have any possibility - // of overlap. + // We typically ask about `BoundMethod` disjointness when we're looking at a + // method call on an intersection type like `A & B`. In that case, the same + // method name would show up on both sides of this check. However for + // completeness, if we're ever comparing `BoundMethod` types with different + // method names, then they're clearly disjoint. self.always() } else { - // If the names match, then disjointness depends on whether the class types are - // disjoint. + // The names match, so `BoundMethod` disjointness depends on whether the bound + // self types are disjoint. Note that this can produce confusing results in the + // face of Liskov violations. For example: + // ``` + // class A: + // def f(self) -> int: ... + // class B: + // def f(self) -> str: ... + // def _(x: Intersection[A, B]): + // x.f() + // ``` + // `class C(A, B)` could inhabit that intersection, but `int` and `str` are + // disjoint, so the type of `x.f()` there is going to be inferred as `Never`. + // That's probably not correct in practice, but the right way to address it is + // to emit a diagnostic on the definition of `C.f`. self.check_type_pair(db, a.self_instance(db), b.self_instance(db)) } } From 327fdcb377a877b54948d33724b1ec5e94eb7d8e Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Wed, 25 Mar 2026 18:44:26 -0700 Subject: [PATCH 3/3] handle the both are @final case --- .../type_properties/is_disjoint_from.md | 48 +++++++++++++++++++ .../ty_python_semantic/src/types/relation.rs | 23 +++++++++ 2 files changed, 71 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md index fa317e287eea0..890765d54c834 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md @@ -506,6 +506,54 @@ static_assert(is_disjoint_from(TypeOf[B().foo], TypeOf[F2().foo])) static_assert(is_disjoint_from(TypeOf[F1().foo], TypeOf[F2().foo])) ``` +### Bound `@final` methods + +Two different `@final` methods are disjoint, even if they share the same name and neither class is +`@final`, because no subclass could satisfy both without overriding one: + +```py +from typing import final +from ty_extensions import TypeOf, is_disjoint_from, static_assert + +class C: + @final + def foo(self) -> None: ... + +class D: + @final + def foo(self) -> None: ... + +static_assert(is_disjoint_from(TypeOf[C().foo], TypeOf[D().foo])) +``` + +We do have to be careful not to get confused when the same `@final` method has different (but +compatible) bound self types: + +```py +class E(C): ... + +# `E.foo` and `C.foo` are the same method, so `E().foo` satisfies both `BoundMethod` types. +static_assert(not is_disjoint_from(TypeOf[E().foo], TypeOf[C().foo])) +static_assert(is_disjoint_from(TypeOf[E().foo], TypeOf[D().foo])) +``` + +Also, we can't establish disjointness when only one of the methods is `@final`. Consider this tricky +multiple inheritance case: + +```py +class F: + def foo(self) -> None: ... + +static_assert(not is_disjoint_from(TypeOf[C().foo], TypeOf[F().foo])) +static_assert(not is_disjoint_from(TypeOf[D().foo], TypeOf[F().foo])) + +class G(C, F): ... + +static_assert(not is_disjoint_from(TypeOf[C().foo], TypeOf[G().foo])) +static_assert(is_disjoint_from(TypeOf[D().foo], TypeOf[G().foo])) +static_assert(not is_disjoint_from(TypeOf[F().foo], TypeOf[G().foo])) +``` + ### `AlwaysTruthy` and `AlwaysFalsy` ```py diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index 4a6cfd0e36aa5..7669cf027a4e7 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -8,6 +8,7 @@ use crate::types::constraints::{ }; use crate::types::cyclic::PairVisitor; use crate::types::enums::is_single_member_enum; +use crate::types::function::FunctionDecorators; use crate::types::set_theoretic::RecursivelyDefined; use crate::types::{ CallableType, ClassBase, ClassType, CycleDetector, DynamicType, KnownBoundMethodType, @@ -2205,6 +2206,28 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { // completeness, if we're ever comparing `BoundMethod` types with different // method names, then they're clearly disjoint. self.always() + } else if a.function(db) != b.function(db) + && a.function(db) + .has_known_decorator(db, FunctionDecorators::FINAL) + && b.function(db) + .has_known_decorator(db, FunctionDecorators::FINAL) + { + // If *both* methods are `@final` (and they're not literally the same + // definition), they must be disjoint. + // + // Note that we can't establish disjointness when only one side is `@final`, + // because we have to worry about cases like this: + // + // ``` + // class A: + // def f(self): ... + // class B: + // @final + // def f(self): ... + // # Valid in this order, though `C(A, B)` would be invalid. + // class C(B, A): ... + // ``` + self.always() } else { // The names match, so `BoundMethod` disjointness depends on whether the bound // self types are disjoint. Note that this can produce confusing results in the