Layer UnionDiagnostic and IntersectionDiagnostic for nested types

When an intersection fails inside a union, report errors with both
union and intersection context:
- Add LayeredDiagnostic that combines both contexts
- Show the correct intersection type (not the full union)
- Include test with snapshot diagnostics demonstrating the layered output

This addresses review comment 9 from PR #22469.
This commit is contained in:
Claude
2026-01-12 22:24:34 +00:00
parent fee0888f76
commit fa9474f3a8
3 changed files with 212 additions and 5 deletions

View File

@@ -834,3 +834,34 @@ def _(flag: bool):
# error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int] & dict[Unknown | str, Unknown | int]`"
f({"y": 1})
```
## Union of intersections with failing bindings
<!-- snapshot-diagnostics -->
When calling a union where one element is an intersection of callables, and all bindings
in that intersection fail, we should report errors with both union and intersection context.
```py
from ty_extensions import Intersection
from typing import Callable
class IntCaller:
def __call__(self, x: int) -> int:
return x
class StrCaller:
def __call__(self, x: str) -> str:
return x
class BytesCaller:
def __call__(self, x: bytes) -> bytes:
return x
def test(f: Intersection[IntCaller, StrCaller] | BytesCaller):
# Call with None - should fail for IntCaller, StrCaller, and BytesCaller
# error: [invalid-argument-type]
# error: [invalid-argument-type]
# error: [invalid-argument-type]
f(None)
```

View File

@@ -0,0 +1,111 @@
---
source: crates/ty_test/src/lib.rs
assertion_line: 623
expression: snapshot
---
---
mdtest name: union.md - Unions in calls - Union of intersections with failing bindings
mdtest path: crates/ty_python_semantic/resources/mdtest/call/union.md
---
# Python source files
## mdtest_snippet.py
```
1 | from ty_extensions import Intersection
2 | from typing import Callable
3 |
4 | class IntCaller:
5 | def __call__(self, x: int) -> int:
6 | return x
7 |
8 | class StrCaller:
9 | def __call__(self, x: str) -> str:
10 | return x
11 |
12 | class BytesCaller:
13 | def __call__(self, x: bytes) -> bytes:
14 | return x
15 |
16 | def test(f: Intersection[IntCaller, StrCaller] | BytesCaller):
17 | # Call with None - should fail for IntCaller, StrCaller, and BytesCaller
18 | # error: [invalid-argument-type]
19 | # error: [invalid-argument-type]
20 | # error: [invalid-argument-type]
21 | f(None)
```
# Diagnostics
```
error[invalid-argument-type]: Argument to bound method `__call__` is incorrect
--> src/mdtest_snippet.py:21:7
|
19 | # error: [invalid-argument-type]
20 | # error: [invalid-argument-type]
21 | f(None)
| ^^^^ Expected `int`, found `None`
|
info: Method defined here
--> src/mdtest_snippet.py:5:9
|
4 | class IntCaller:
5 | def __call__(self, x: int) -> int:
| ^^^^^^^^ ------ Parameter declared here
6 | return x
|
info: Intersection element `IntCaller` is incompatible with this call site
info: Attempted to call intersection type `IntCaller & StrCaller`
info: Attempted to call union type `(IntCaller & StrCaller) | BytesCaller`
info: rule `invalid-argument-type` is enabled by default
```
```
error[invalid-argument-type]: Argument to bound method `__call__` is incorrect
--> src/mdtest_snippet.py:21:7
|
19 | # error: [invalid-argument-type]
20 | # error: [invalid-argument-type]
21 | f(None)
| ^^^^ Expected `str`, found `None`
|
info: Method defined here
--> src/mdtest_snippet.py:9:9
|
8 | class StrCaller:
9 | def __call__(self, x: str) -> str:
| ^^^^^^^^ ------ Parameter declared here
10 | return x
|
info: Intersection element `StrCaller` is incompatible with this call site
info: Attempted to call intersection type `IntCaller & StrCaller`
info: Attempted to call union type `(IntCaller & StrCaller) | BytesCaller`
info: rule `invalid-argument-type` is enabled by default
```
```
error[invalid-argument-type]: Argument to bound method `__call__` is incorrect
--> src/mdtest_snippet.py:21:7
|
19 | # error: [invalid-argument-type]
20 | # error: [invalid-argument-type]
21 | f(None)
| ^^^^ Expected `bytes`, found `None`
|
info: Method defined here
--> src/mdtest_snippet.py:13:9
|
12 | class BytesCaller:
13 | def __call__(self, x: bytes) -> bytes:
| ^^^^^^^^ -------- Parameter declared here
14 | return x
|
info: Union variant `BytesCaller` is incompatible with this call site
info: Attempted to call union type `(IntCaller & StrCaller) | BytesCaller`
info: rule `invalid-argument-type` is enabled by default
```

View File

@@ -538,19 +538,38 @@ impl<'db> Bindings<'db> {
return;
}
let is_union = self.elements.len() > 1;
// For intersection elements, use priority hierarchy
if element.is_intersection() {
// Find the highest priority error among bindings in this element
let max_priority = element.error_priority();
// Construct the intersection type from the bindings
let intersection_type = IntersectionType::from_elements(
context.db(),
element.bindings.iter().map(|b| b.callable_type),
);
// Only report errors from bindings with the highest priority
for binding in &element.bindings {
if binding.error_priority() == max_priority {
let intersection_diag = IntersectionDiagnostic {
callable_type: self.callable_type(),
binding,
};
binding.report_diagnostics(context, node, Some(&intersection_diag));
if is_union {
// Use layered diagnostic for intersection inside a union
let layered_diag = LayeredDiagnostic {
union_callable_type: self.callable_type(),
intersection_callable_type: intersection_type,
binding,
};
binding.report_diagnostics(context, node, Some(&layered_diag));
} else {
// Just intersection, no union context needed
let intersection_diag = IntersectionDiagnostic {
callable_type: intersection_type,
binding,
};
binding.report_diagnostics(context, node, Some(&intersection_diag));
}
}
}
} else {
@@ -4954,6 +4973,52 @@ impl CompoundDiagnostic for IntersectionDiagnostic<'_, '_> {
}
}
/// Contains both union and intersection context for layered diagnostics.
///
/// Used when an intersection fails inside a union - we want to report both
/// that this is a union variant AND that this is an intersection element.
struct LayeredDiagnostic<'b, 'db> {
/// The type of the union.
union_callable_type: Type<'db>,
/// The type of the intersection (for intersection context).
intersection_callable_type: Type<'db>,
/// The specific binding that failed.
binding: &'b CallableBinding<'db>,
}
impl CompoundDiagnostic for LayeredDiagnostic<'_, '_> {
fn add_context(&self, db: &dyn Db, diag: &mut Diagnostic) {
// Add intersection context first (more specific)
let sub = SubDiagnostic::new(
SubDiagnosticSeverity::Info,
format_args!(
"Intersection element `{callable_ty}` is incompatible with this call site",
callable_ty = self.binding.callable_type.display(db),
),
);
diag.sub(sub);
let sub = SubDiagnostic::new(
SubDiagnosticSeverity::Info,
format_args!(
"Attempted to call intersection type `{}`",
self.intersection_callable_type.display(db)
),
);
diag.sub(sub);
// Then add union context (outer layer)
let sub = SubDiagnostic::new(
SubDiagnosticSeverity::Info,
format_args!(
"Attempted to call union type `{}`",
self.union_callable_type.display(db)
),
);
diag.sub(sub);
}
}
/// Represents the matching overload of a function literal that was found via the overload call
/// evaluation algorithm.
struct MatchingOverloadLiteral<'db> {