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

1 participant