Skip to content

Pyrefly doesn't fully narrow tuple in exhaustible match-case #5206

Open

Description

@StefanBRas

Describe the Bug

Not sure how to best describe it so I'll just show with an example:

from typing import assert_type
class A: ...

def as_singleton(elem: A | None)-> None:
    match elem:
        case None:
            assert_type(elem, None) # OK!
        case _:
            assert_type(elem, A) # OK!
            
def as_tuple(elem: A | None)-> None:
    match (elem,):
        case (None,):
            assert_type(elem, None) # OK!
        case _:
            assert_type(elem, A) # FAIL! A | None

I'd except the as_tuple to work the same as the as_singleton here. (I get the same error if I match on (_,) instead of _ in the last case)

For reference ty does the same, Pyright correctly inferes it (can't link to the pyright playground as it's down for me right now)

Not sure it's related but I originally hit it when I had something like:

class A: ...

def merge(left: A | None, right: A | None) -> A:
    match left, right:
        case None, None:
            return A()
        case None, _:
            return right # FAIL! A() | None
        case _, None:
            return left # FAIL! A() | None
        case _, _:
            return right # FAIL! A() | None

Which seems exhaustive to me? When I reduced it I found the smaller example, let me know if I should file this as a separate issue. I'm not sure about the semantics of a wildcard here

Sandbox Link

https://pyrefly.org/sandbox/?project=v2.tZXNDoIwEIRfZQ0H9EIvnrj1YmI00TcgDTZgAmJs8fmdLr9Fg3iwx2Znu02_mf4QmaMQ6qwkY4qiqANXmQRMZYW2gFcXuoxJtthufA5KZdMcOKFkwAm5pqfoMjTDudwVacsOCOh0WE3kyVetfBO61V_A1oiGJcM37cZ-4QHWrtDb_tMVdnJ_XPUjMhATHAM6NxvUviyCGBxnNeIefOLZAiS9ptzau4mF6OTVIxP6Ji5VaoSnEOScp57qyp-I344qto9B288-2C7yQdjYIJxzQehMgIpZsF8

(Only applicable for extension issues) IDE Information

No response

Activity

  1. added
    narrowingIssues with narrowing - root cause is usually narrowing, flow handling, or both
    on Oct 12, 2026
  2. StefanBRas commented on Oct 13, 2026

    @StefanBRas
    Author

    I tried avoiding wildcards but got something similar, let me know if this should be a separate issue. It does seem related to how narrowing works for tuples though. Also it works differently in the sandbox versus my local install (v1.3.2), it differs in pick2.

    from typing import assert_never
    class A: ...
    
    def pick(left: A | None, right: A | None) -> A: # ERROR sandbox.py:4:46-47: Function declared to return `A`, but one or more paths are missing an explicit `return` [bad-return]
        match left, right:
            case None, None:
                return A()
            case None, A():
                return right
            case A(), None:
                return left
    
    # However it correctly asserts it to never (in sandbox, not in locally installed version v1.3.2)
    
    def pick2(left: A | None, right: A | None) -> A:
        match left, right:
            case None, None:
                return A()
            case None, A():
                return right
            case A(), None:
                return left
            case A(), A():
                return right
            case wildcard:
                assert_never(wildcard) # Works in sandbox, not in v1.3.2
    
    # Looks to be related to tuples, this works fine
    
    class B: ...
    def pick(left: A | B) -> A | B:
        match left:
            case A():
                return left
            case B():
                return left
            

    sandbox: https://pyrefly.org/sandbox/?project=v2.1VZPT4MwFP8qL-GwmUyIk2jCjSUaDyaaXTx4gUEFMrYu0G367f29lmYMkGxHb9D09bW_f-0VkdmOJOulMCDXda1yd0WynpbiSwUUNnpFPBVZ3hrQkkCZQ0_L5duSTl0DP_Afbv3HgJ4bB0BIaAMUSEkrxyiMZrTaIy9gBghhA9B0YNTwkKBNUUPXGcVbEt-7skigt8iURvS5ilMgyz9gwohKJTnxlu1GT_JGzormDB1DtdwRsiQHKjA-XKC7dEoweawHb48hduhFHhl9qB6hWrF6yp-GFwSzYpg0PTSFNRpkZwQ9sVVKmbCE8QnbI5FTsko93Ln37lx7y9I4v5DHfwdjv-KqHseiTJO4SjsVbW9M7ZwbiPxDVmtQ02fDYG5ofZUSk0DeSqB3GSMc-E_tcU3hflU5Mv2oF0IM6bvGuG_RuG_AewvDD3_1OOqS8ycAA4gtxub2I9GhdzNgzw_dIkuzPZ4cUB727uC1IShXalcHnmfLZZV5YuulMqm9swqPOP3jQ1zoh8z5ciR1hNdYdjiL_YuyeGKieDKWxBMOYswYDddf

  3. self-assigned this
    on Nov 5, 2026
  4. rchen152 commented on Nov 13, 2026

    @rchen152
    Contributor

    Related:

    def without_fallback(left: bool, right: bool) -> int:  # spurious bad-return
        match left, right:
            case True, True: return 0
            case True, False: return 1
            case False, True: return 2
            case False, False: return 3
    
  5. rchen152 commented on Nov 13, 2026

    @rchen152
    Contributor

    Looks like the original two examples work in the sandbox now, but #5206 (comment) and the pick example are still broken:

    class A: ...
    
    def pick(left: A | None, right: A | None) -> A: # ERROR sandbox.py:4:46-47: Function declared to return `A`, but one or more paths are missing an explicit `return` [bad-return]
        match left, right:
            case None, None:
                return A()
            case None, A():
                return right
            case A(), None:
                return left
            case A(), A():
                return right
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

narrowingIssues with narrowing - root cause is usually narrowing, flow handling, or both

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions