[ty] Preserve narrowing after guarded match subject captures (#29558)

When a match case captures the subject under its own name and its guard
fails, later cases now narrow that capture. Previously, those cases
could retain a wider type because narrowing targeted only the binding
read before the match.

Retain bindings that capture the entire subject under its original name,
including `as` patterns and OR alternatives. Captures of extracted
values and assignments made by guards remain distinct from the saved
subject.

Fixes astral-sh/ty#4761.
This commit is contained in:
Carl Meyer authored and GitHub committed 2026-10-26 15:21:49 -07:00
1 parent 3c8166b683
commit 95888b2edf
2 files changed
+144

No files matched your search

+44
View File
@@ -2887,6 +2887,42 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
(predicate, predicate_id)
}
/// Retain captures that bind the entire subject to its original name. These definitions
/// still refer to the saved subject if a guard fails, so later cases can narrow them.
fn extend_match_subject_bindings(
&self,
pattern: &ast::Pattern,
symbol: ScopedSymbolId,
bindings: &mut SmallVec<[ScopedDefinitionId; 2]>,
) {
match pattern {
ast::Pattern::MatchAs(pattern) => {
if let Some(name) = &pattern.name
&& let Some(definition) = self
.definitions_by_node
.get(&name.into())
.and_then(|definitions| definitions.first())
{
let use_def = self.current_use_def_map();
bindings.extend(use_def.symbol_binding_definition_ids(symbol).filter(
|binding| use_def.definition(*binding).definition() == Some(*definition),
));
}
if let Some(pattern) = &pattern.pattern {
self.extend_match_subject_bindings(pattern, symbol, bindings);
}
}
ast::Pattern::MatchOr(pattern) => {
for pattern in &pattern.patterns {
self.extend_match_subject_bindings(pattern, symbol, bindings);
}
}
// Structural patterns can capture extracted values. Identifying whole-subject
// captures in class patterns requires type information.
_ => {}
}
}
/// Record an expression that needs to be a Salsa ingredient, because we need to infer its type
/// standalone (type narrowing tests, RHS of an assignment.)
fn add_standalone_expression(&mut self, expression_node: &ast::Expr) -> Expression<'db> {
@@ -4922,6 +4958,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
};
subject_targets.push((place, bindings));
}
let subject_place = PlaceExpr::try_from_expr(subject.as_ref())
.and_then(|place| self.current_place_table().place_id((&place).into()));
let places = self.current_place_table();
let ast_ids = self.current_ast_ids();
let mut sequence_subject_targets =
@@ -4974,6 +5012,12 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
self.record_exception_checkpoint_if(Self::pattern_can_raise(&case.pattern));
self.visit_pattern(&case.pattern);
self.current_match_case = None;
if let Some((ScopedPlaceId::Symbol(symbol), bindings)) = subject_targets
.iter_mut()
.find(|(place, _)| Some(*place) == subject_place)
{
self.extend_match_subject_bindings(&case.pattern, *symbol, bindings);
}
// unlike in [Stmt::If], we don't reset [no_case_matched]
// here because the effects of visiting a pattern is binding
// symbols, and this doesn't occur unless the pattern
@@ -3545,6 +3545,95 @@ def match_tuple_expression_guard_rebinding(
reveal_type(b) # revealed: TupleSubjectB1
```
## Captures of the subject across cases
### Narrowing guards
Capturing the subject under its original name keeps its value. If the guard fails, later patterns
can still narrow that capture.
```py
def narrow_after_capture(value: int | str | bytes) -> None:
match value:
case value if isinstance(value, int):
reveal_type(value) # revealed: int
case str():
reveal_type(value) # revealed: str
case bytes():
reveal_type(value) # revealed: bytes
```
### Constrained `as` patterns
If the first pattern matches but its guard fails, the captured integer cannot reach the string case.
```py
def narrow_after_as_capture(value: int | str, flag: bool) -> None:
match value:
case int() as value if flag:
pass
case str():
reveal_type(value) # revealed: str
```
### Or pattern alternatives
Each alternative can capture the whole subject under its original name.
```py
def narrow_after_or_capture(value: int | str | bytes, flag: bool) -> None:
match value:
case (int() as value) | (str() as value) if flag:
pass
case str():
reveal_type(value) # revealed: str
```
A capture can also refer to the whole subject in one alternative and an extracted element in
another. Matching the original tuple in a later case does not make its captured integer unreachable.
```py
def mixed_or_captures(subject: int | tuple[int], flag: bool) -> None:
value = subject
match value:
case (int() as value) | [value] if flag:
pass
case tuple():
reveal_type(value) # revealed: int
```
### Reassignment in guards
A guard can assign a different value to the captured name. Later cases still match the saved
subject, so their patterns do not narrow that assignment.
```py
def reassign_capture(value: int | str, flag: bool) -> None:
match value:
case value if (value := "changed") and flag:
pass
case int():
reveal_type(value) # revealed: Literal["changed"]
```
### Member subjects
Capturing an attribute under the name of its owner replaces the owner with the attribute value.
Narrowing the saved attribute does not narrow this new binding of the owner's name.
```py
class Holder:
attr: int | str
def capture_member(holder: Holder) -> None:
value = holder
match value.attr:
case value if False:
pass
case int():
reveal_type(value) # revealed: int | str
```
## Named-expression subjects
A named expression creates a new binding for the subject. The successful pattern narrows that
@@ -3569,6 +3658,17 @@ def match_named_expression_subject_capture(value: tuple[int]) -> None:
reveal_type(subject) # revealed: int
```
Capturing the entire subject under its new name preserves narrowing in later cases.
```py
def match_named_expression_guarded_capture(value: int | str) -> None:
match subject := value:
case subject if False:
pass
case int():
reveal_type(subject) # revealed: int
```
## Cycles in pattern binding types
Pattern captures can affect the type of a later match subject, including through a loop or a