Skip to content

Subtract a union member matched by a literal sequence-pattern element#3967

Open
markselby9 wants to merge 1 commit into
facebook:mainfrom
markselby9:feat/3883-seq-pattern-narrow
Open

Subtract a union member matched by a literal sequence-pattern element#3967
markselby9 wants to merge 1 commit into
facebook:mainfrom
markselby9:feat/3883-seq-pattern-narrow

Conversation

@markselby9

Copy link
Copy Markdown
Contributor

Summary

Closes #3883.

A match arm whose sequence pattern has a literal element — e.g. case ("b", _) against the union member tuple[Literal["b"], int] — is irrefutable for that member, but the member was not subtracted from the subject in the fall-through / exhaustiveness branch. A full-capture pattern (case (k, v)) did subtract correctly. So this match left a non-Never remainder when it should have been exhaustive:

from typing import Literal, assert_never

def f(value: Literal["a"] | tuple[Literal["b"], int] | tuple[Literal["c"], int]) -> None:
    match value:
        case "a":
            pass
        case ("b", _):
            pass
        case ("c", _):
            pass
        case _ as unreachable:
            assert_never(unreachable)  # was: error, `tuple[...] | tuple[...]` not `Never`

Root cause

The MatchSequence arm already emits the right scope narrow — And(IsSequence, LenEq(n), <element narrows>), where a value element contributes an Eq(v) narrow on the element facet Index(i). But NarrowOps::and_all appends an AtomicNarrowOp::Placeholder for the subject whenever a sub-pattern contributes no narrow, which is exactly what an irrefutable element like _ does. Negating And(..., Placeholder) gives Or(..., Placeholder), and Placeholder narrows to the full type — so the union re-includes the member and nothing is subtracted. Placeholders were only stripped when every element was irrefutable, which a literal element defeats.

Fix

Strip the placeholders for any star-free sequence pattern whose elements are all irrefutable, value, or singleton patterns (this is the maintainer's suggestion on the issue: treat variable bindings like wildcards, and let the literal element contribute its condition).

This is sound because the subtraction is gated by the type-directed facet narrowing (atomic_narrow_for_facet's NotEq/IsNot arms), which removes a union member only when the element type guarantees the match (exact literal/singleton) and preserves it otherwise:

  • ("b", _) vs tuple[Literal["b"], int] → subtracted ✓
  • ("b", _) vs tuple[str, int] (wider) → preserved ✓
  • (1.0, 2.0) vs tuple[float, float] → preserved ✓ (unchanged)

Star patterns are excluded (post-star elements get no facet subject), and nested/OR/class sub-patterns fall to the existing conservative path — no behavior change there. Guarded arms (case ("b", _) if cond:) still don't subtract, because the guard re-introduces a placeholder.

Test plan

  • New tests in pyrefly/lib/test/pattern_match.rs:
    • test_match_sequence_literal_element_narrows_out_of_union — the issue repro (assert_never reached)
    • test_match_sequence_literal_element_with_capture_narrows — the case ("b", v) capture form
    • test_match_sequence_literal_element_wider_type_no_strip — over-narrow guard: ("b", _) vs tuple[str, int] is preserved
  • Existing test_match_sequence_refutable_subpattern_no_strip and test_match_sequence_star_pattern_narrows still pass.
  • Full lib suite: cargo test -p pyrefly --lib5978 passed, 0 failed.
  • python3 test.py --no-test --no-tensor-shapes --no-conformance --no-jsonschema — clean.

@github-actions

This comment has been minimized.

A `match` arm like `case ("b", _)` against a union member
`tuple[Literal["b"], int]` is irrefutable for that member, yet the member was
not subtracted from the subject in the fall-through/exhaustiveness branch — so
an otherwise-exhaustive match left a non-`Never` remainder. A full-capture
`case (k, v)` did subtract correctly.

The sequence pattern already emits an `Eq`/`Is` narrow on the element facet, but
`and_all` appends a `Placeholder` for every irrefutable element (e.g. the `_`),
and negating `And(..., Placeholder)` yields `Or(..., Placeholder)`, which
re-includes the whole type and blocks subtraction. Placeholders were only
stripped when every element was irrefutable, which a literal element defeats.

Strip the placeholders for any star-free pattern whose elements are all
irrefutable, value, or singleton patterns. This is sound because the
type-directed facet narrowing subtracts a member only when the element type
guarantees the match (exact literal/singleton) and preserves it otherwise
(e.g. `("b", _)` against `tuple[str, int]`).

Closes facebook#3883
@markselby9 markselby9 force-pushed the feat/3883-seq-pattern-narrow branch from a5ac090 to 049c8c3 Compare June 29, 2026 04:10
@github-actions github-actions Bot added size/m and removed size/m labels Jun 29, 2026
@github-actions

Copy link
Copy Markdown

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sequence pattern in match/case with a literal element does not narrow a type out of a union

1 participant