Skip to content

[ty] Add support for narrowing on tuple match cases#25493

Merged
charliermarsh merged 15 commits into
mainfrom
charlie/pattern-match
Jun 6, 2026
Merged

[ty] Add support for narrowing on tuple match cases#25493
charliermarsh merged 15 commits into
mainfrom
charlie/pattern-match

Conversation

@charliermarsh

@charliermarsh charliermarsh commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds precise narrowing for fixed-length sequence match patterns.

For a pattern like [int(), str()], we synthesize a protocol whose __len__ and indexed __getitem__ methods
encode the pattern’s length and element types:

def f(value: object) -> None:
    match value:
        case [int(), str()]:
            reveal_type(len(value))  # Literal[2]
            reveal_type(value[0])  # int
            reveal_type(value[1])  # str

We distinguish between the type necessary to match a pattern and the type guaranteed to match it. Positive
narrowing uses the necessary type, while negative narrowing and reachability only subtract values guaranteed to
match. This allows exact sequence patterns to participate in exhaustiveness analysis without incorrectly treating
refutable nested patterns as exhaustive.

Closes astral-sh/ty#561.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label May 30, 2026
@charliermarsh charliermarsh changed the title [ty] Narrow exact sequence patterns [ty] Add support for narrowing on tuple match cases May 30, 2026
@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from d5ead3f to 974d660 Compare May 30, 2026 20:36
@astral-sh-bot

astral-sh-bot Bot commented May 30, 2026

Copy link
Copy Markdown

Typing conformance results

The percentage of diagnostics emitted that were expected errors held steady at 92.16%. The percentage of expected errors that received a diagnostic held steady at 87.31%. The number of fully passing files held steady at 92/134.

Summary

How are test cases classified?

Each test case represents one expected error annotation or a group of annotations sharing a tag. Counts are per test case, not per diagnostic — multiple diagnostics on the same line count as one. Required annotations (E) are true positives when ty flags the expected location and false negatives when it does not. Optional annotations (E?) are true positives when flagged but true negatives (not false negatives) when not. Tagged annotations (E[tag]) require ty to flag exactly one of the tagged lines; tagged multi-annotations (E[tag+]) allow any number up to the tag count. Flagging unexpected locations counts as a false positive.

Metric Old New Diff Outcome
True Positives 929 929 +0
False Positives 79 79 +0
False Negatives 135 135 +0
Total Diagnostics 1055 1055 +0
Precision 92.16% 92.16% +0.00%
Recall 87.31% 87.31% +0.00%
Passing Files 92/134 92/134 +0

True positives changed (5)

5 diagnostics
Test case Diff

tuples_type_compat.py:101

-error[type-assertion-failure] Type `tuple[int] | tuple[str, str] | tuple[int, *tuple[str, ...], int]` does not match asserted type `tuple[int]`
+error[type-assertion-failure] Type `tuple[int] | (tuple[int, *tuple[str, ...], int] & <Protocol with members '__getitem__', '__len__'>)` does not match asserted type `tuple[int]`

tuples_type_compat.py:106

-error[type-assertion-failure] Type `tuple[int] | tuple[str, str] | tuple[int, *tuple[str, ...], int]` does not match asserted type `tuple[str, str] | tuple[int, int]`
+error[type-assertion-failure] Type `tuple[str, str] | (tuple[int, *tuple[str, ...], int] & <Protocol with members '__getitem__', '__len__'> & ~<Protocol with members '__getitem__', '__len__'>)` does not match asserted type `tuple[str, str] | tuple[int, int]`

tuples_type_compat.py:111

-error[type-assertion-failure] Type `tuple[int] | tuple[str, str] | tuple[int, *tuple[str, ...], int]` does not match asserted type `tuple[int, str, int]`
+error[type-assertion-failure] Type `tuple[int, *tuple[str, ...], int] & <Protocol with members '__getitem__', '__len__'> & ~<Protocol with members '__getitem__', '__len__'> & ~<Protocol with members '__getitem__', '__len__'>` does not match asserted type `tuple[int, str, int]`

tuples_type_compat.py:126

-error[type-assertion-failure] Type `tuple[int | str, int | str]` does not match asserted type `tuple[int | str, str]`
+error[type-assertion-failure] Type `tuple[int | str, int | str] & <Protocol with members '__getitem__', '__len__'>` does not match asserted type `tuple[int | str, str]`

tuples_type_compat.py:129

-error[type-assertion-failure] Type `tuple[int | str, int | str]` does not match asserted type `tuple[int | str, int]`
+error[type-assertion-failure] Type `tuple[int | str, int | str] & ~<Protocol with members '__getitem__', '__len__'>` does not match asserted type `tuple[int | str, int]`

@astral-sh-bot

astral-sh-bot Bot commented May 30, 2026

Copy link
Copy Markdown

Memory usage report

Memory usage unchanged ✅

@astral-sh-bot

astral-sh-bot Bot commented May 30, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch 7 times, most recently from 84e2755 to 26466d9 Compare May 30, 2026 21:04
@charliermarsh charliermarsh force-pushed the charlie/tuple-match branch from aea236d to 06e0ff9 Compare June 3, 2026 14:03
@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from 2af8f75 to 4a1649a Compare June 3, 2026 15:14
Base automatically changed from charlie/tuple-match to main June 3, 2026 16:23
@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from 90ab3f9 to c87d1de Compare June 3, 2026 17:21
@charliermarsh charliermarsh marked this pull request as ready for review June 3, 2026 17:24
@charliermarsh charliermarsh marked this pull request as draft June 3, 2026 17:52
@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from c87d1de to 256ced0 Compare June 3, 2026 17:52
@charliermarsh

Copy link
Copy Markdown
Member Author

(In draft due to performance regression...)

@AlexWaygood AlexWaygood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only taken a pass at the tests so far

Comment thread crates/ty_python_semantic/resources/mdtest/narrow/match.md
Comment thread crates/ty_python_semantic/resources/mdtest/narrow/match.md Outdated
Comment thread crates/ty_python_semantic/resources/mdtest/narrow/match.md Outdated
Comment thread crates/ty_python_semantic/resources/mdtest/narrow/match.md
Comment thread crates/ty_python_semantic/src/types/match_pattern.rs Outdated
Comment thread crates/ty_python_semantic/src/types/match_pattern.rs Outdated
Comment thread crates/ty_python_semantic/src/types/match_pattern.rs Outdated
Comment thread crates/ty_python_semantic/src/types/match_pattern.rs Outdated
@carljm carljm removed their request for review June 4, 2026 15:25
@AlexWaygood

This comment was marked as resolved.

@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from 37e0039 to 6ab34ac Compare June 5, 2026 13:56

@AlexWaygood AlexWaygood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, thank you!

Comment thread crates/ty_python_core/src/predicate.rs Outdated
/// whether the pattern accepts additional sequence elements.
#[derive(Debug, Clone, Hash, PartialEq, salsa::Update, get_size2::GetSize)]
pub struct SequencePatternPredicateKind<'db> {
pub patterns: Vec<PatternPredicateKind<'db>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub patterns: Vec<PatternPredicateKind<'db>>,
pub patterns: Box<[PatternPredicateKind<'db>]>,

Feels like a boxed slice here would use less memory and better reflects the fact that this is immutable data. (No other code needs to change -- you can .collect() directly into a boxed slice)

Comment thread crates/ty_python_core/src/predicate.rs Outdated
let star_index = self
.patterns
.iter()
.position(|pattern| matches!(pattern, PatternPredicateKind::Unsupported))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.position(|pattern| matches!(pattern, PatternPredicateKind::Unsupported))?;
.position(|pattern| pattern == &PatternPredicateKind::Unsupported)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the only kind of match pattern that remains unsupported is match-star patterns, so I think we could rename PatternPredicateKind::Unsupported to PatternPredicateKind::MatchStar at this point. It would make this function more "obviously correct" (I had to look through the code to see whether there were other still-unsupported kinds of match patterns to check whether this logic was accurate)

Comment thread crates/ty_python_core/src/predicate.rs Outdated
self.patterns.len() == 1 && self.has_star
}

pub fn is_exact_length(&self) -> bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn is_exact_length(&self) -> bool {
pub const fn is_exact_length(&self) -> bool {

Comment thread crates/ty_python_core/src/builder.rs Outdated
Comment on lines +1940 to +1950
PatternPredicateKind::Sequence(SequencePatternPredicateKind {
patterns: pattern
.patterns
.iter()
.map(|pattern| self.predicate_kind(pattern))
.collect(),
has_star: pattern
.patterns
.iter()
.any(|pattern| matches!(pattern, ast::Pattern::MatchStar(_))),
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can do this with one iteration

Suggested change
PatternPredicateKind::Sequence(SequencePatternPredicateKind {
patterns: pattern
.patterns
.iter()
.map(|pattern| self.predicate_kind(pattern))
.collect(),
has_star: pattern
.patterns
.iter()
.any(|pattern| matches!(pattern, ast::Pattern::MatchStar(_))),
})
let mut has_star = false;
let patterns = pattern
.patterns
.iter()
.map(|pattern| {
if matches!(pattern, ast::Pattern::MatchStar(_)) {
has_star = true;
}
self.predicate_kind(pattern)
})
.collect();
PatternPredicateKind::Sequence(SequencePatternPredicateKind { patterns, has_star })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like we yet have any tests for the & ~str & ~bytes & ~bytearray part of the intersection. E.g.

def f(x: str | tuple[int, int]):
    match x:
        case (a, b):
            reveal_type(a)  # revealed: @Todo
            reveal_type(b)  # revealed: @Todo
        case _:
            reveal_type(x)  # revealed: str

We wouldn't get the correct answer in the second branch there if it weren't for the & ~str & ~bytes & ~bytearray part of the intersection. We should add similar tests for unions with bytes and bytearray too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this part doesn't work yet since we don't support narrowing on the assigned names?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Gah, sorry, I missed the @Todo in your own example...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's the revealed: str in the second branch that already works on your PR! and it wouldn't work unless we intersected the type of x with & ~str & ~bytes & ~bytearray in the first branch

Comment on lines +2018 to +2020
/// Positive narrowing uses this as a sound over-approximation: the result
/// may include values that fail nested refutable checks, but matching
/// values cannot fall outside it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is somewhat jargony. It would be great to add an example of Python code that demonstrates what "sound over-approximation" and "nested refutable checks" mean

@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from 6ab34ac to afa86eb Compare June 6, 2026 18:20
/// For example, consider:
///
/// ```python
/// case [int(real=0)]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// case [int(real=0)]:
/// def f(x: list[object]):
/// match x:
/// case [int(real=0)]:
/// reveal_type(x)

Comment on lines +2037 to +2041
/// Every match is a one-element sequence containing an `int`, so the
/// returned type records those facts. It does not record the nested
/// `real=0` check, so it can also contain values such as `[1]` that fail
/// the pattern at runtime. This is intentional for positive narrowing:
/// the result may retain extra values, but cannot exclude a possible match.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think I understand what this is trying to say, but the wording here still feels a bit awkward to me FWIW... codex is not good at writing prose)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2026-06-06 at 2 35 19 PM

@charliermarsh charliermarsh force-pushed the charlie/pattern-match branch from afa86eb to 210840d Compare June 6, 2026 18:27
@charliermarsh charliermarsh merged commit 757d823 into main Jun 6, 2026
58 checks passed
@charliermarsh charliermarsh deleted the charlie/pattern-match branch June 6, 2026 18:41
thejchap added a commit to thejchap/ruff that referenced this pull request Jun 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support narrowing on tuple match cases

2 participants