Summary
A PEP 695 alias of an intersection of callables does not contribute constraints when passed to a generic function. This can produce a different inferred return type and a false-positive argument error compared with the equivalent inline annotation.
Because today our handling of constraints from intersections on main is not good, this can look like the alias gives a better result from ignoring the intersection evidence. But after that is fixed in astral-sh/ruff#28303, we can more clearly see the problem caused by losing evidence from the alias:
from typing import Callable, reveal_type
type Both = "Callable[[int], int] & Callable[[str], str]"
def call[T](f: Callable[[T], T], x: T) -> T:
return f(x)
def example(
named: Both,
inline: "Callable[[int], int] & Callable[[str], str]",
x: bool,
):
reveal_type(call(named, x)) # bool; invalid-argument-type
reveal_type(call(inline, x)) # int; accepted
The named call reports:
Argument to function `call` is incorrect: Expected `(bool, /) -> bool`, found `Both`
Both calls should be accepted and reveal int: bool is a subtype of int, and the callback's integer signature accepts an int and returns an int. Matching that signature requires T = int, even though the other argument has type bool. Naming the intersection with an alias should not affect inference.
Cause
In SpecializationBuilder::infer_map_impl, the formal Callable arm runs before the arm that expands aliases in the actual type. It calls try_upcast_to_callable, which expands the alias but cannot convert the resulting general intersection into a callable. The early return Ok(()) skips inference from that argument entirely.
Consequently, only x contributes to inference in the named case, producing T = bool. Argument checking then rejects the callback because its integer signature returns int, not necessarily bool.
An inline intersection reaches the actual-intersection inference arm instead. astral-sh/ruff#28303 fixes that arm's handling of alternative specializations, but leaves this alias-handling gap. This issue specifically tracks preserving the same inference evidence through the alias; it is related to #3557.
Version
Reproduced on Ruff commit 0ecefedd582415888c39275e09ecae05465c10ef (astral-sh/ruff#28303):
ty ruff/0.16.6+20 (0ecefedd5 2026-09-03)
Summary
A PEP 695 alias of an intersection of callables does not contribute constraints when passed to a generic function. This can produce a different inferred return type and a false-positive argument error compared with the equivalent inline annotation.
Because today our handling of constraints from intersections on main is not good, this can look like the alias gives a better result from ignoring the intersection evidence. But after that is fixed in astral-sh/ruff#28303, we can more clearly see the problem caused by losing evidence from the alias:
The
namedcall reports:Both calls should be accepted and reveal
int:boolis a subtype ofint, and the callback's integer signature accepts anintand returns anint. Matching that signature requiresT = int, even though the other argument has typebool. Naming the intersection with an alias should not affect inference.Cause
In
SpecializationBuilder::infer_map_impl, the formalCallablearm runs before the arm that expands aliases in the actual type. It callstry_upcast_to_callable, which expands the alias but cannot convert the resulting general intersection into a callable. The earlyreturn Ok(())skips inference from that argument entirely.Consequently, only
xcontributes to inference in the named case, producingT = bool. Argument checking then rejects the callback because its integer signature returnsint, not necessarilybool.An inline intersection reaches the actual-intersection inference arm instead. astral-sh/ruff#28303 fixes that arm's handling of alternative specializations, but leaves this alias-handling gap. This issue specifically tracks preserving the same inference evidence through the alias; it is related to #3557.
Version
Reproduced on Ruff commit
0ecefedd582415888c39275e09ecae05465c10ef(astral-sh/ruff#28303):