Skip to content

Conversation

@sharkdp
Copy link
Contributor

@sharkdp sharkdp commented Jul 23, 2025

Summary

Implements proper reachability analysis and — in effect — exhaustiveness checking for match statements. This allows us to check the following code without any errors (leads to "can implicitly return None" on main):

from enum import Enum, auto

class Color(Enum):
    RED = auto()
    GREEN = auto()
    BLUE = auto()

def hex(color: Color) -> str:
    match color:
        case Color.RED:
            return "#ff0000"
        case Color.GREEN:
            return "#00ff00"
        case Color.BLUE:
            return "#0000ff"

Note that code like this already worked fine if there was a assert_never(color) statement in a catch-all case, because we would then consider that assert_never call terminal. But now this also works without the wildcard case. Adding a member to the enum would still lead to an error here, if that case would not be handled in hex.

What needed to happen to support this is a new way of evaluating match pattern constraints. Previously, we would simply compare the type of the subject expression against the patterns. For the last case here, the subject type would still be Color and the value type would be Literal[Color.BLUE], so we would infer an ambiguous truthiness.

Now, before we compare the subject type against the pattern, we first generate a union type that corresponds to the set of all values that would have definitely been matched by previous patterns. Then, we build a "narrowed" subject type by computing subject_type & ~already_matched_type, and compare that against the pattern type. For the example here, already_matched_type = Literal[Color.RED] | Literal[Color.GREEN], and so we have a narrowed subject type of Color & ~(Literal[Color.RED] | Literal[Color.GREEN]) = Literal[Color.BLUE], which allows us to infer a reachability of AlwaysTrue.

A note on negated reachability constraints

It might seem that we now perform duplicate work, because we also record negated reachability constraints. But that is still important for cases like the following (and possibly also for more realistic scenarios):

from typing import Literal

def _(x: int | str):
    match x:
        case None:
            pass # never reachable
        case _:
            y = 1

    y

closes astral-sh/ty#99

Test Plan

  • I verified that this solves all examples from the linked ticket (the first example needs a PEP 695 type alias, because we don't support legacy type aliases yet)
  • Verified that the ecosystem changes are all because of removed false positives
  • Updated tests

@sharkdp sharkdp added ty Multi-file analysis & type inference ecosystem-analyzer labels Jul 23, 2025
@sharkdp sharkdp changed the title [ty] Exhaustiveness checking / reachability modeling for match statements [ty] Exhaustiveness checking & reachability for match statements Jul 23, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Jul 23, 2025

mypy_primer results

Changes were detected when running on open source projects
pwndbg (https://github.com/pwndbg/pwndbg)
- pwndbg/commands/mallocng.py:345:55: error[invalid-return-type] Function can implicitly return `None`, which is not assignable to return type `str`
- Found 2246 diagnostics
+ Found 2245 diagnostics

mitmproxy (https://github.com/mitmproxy/mitmproxy)
- mitmproxy/contentviews/_utils.py:50:13: error[type-assertion-failure] Argument does not have asserted type `Never`
- Found 1816 diagnostics
+ Found 1815 diagnostics

rotki (https://github.com/rotki/rotki)
- rotkehlchen/externalapis/cryptocompare.py:400:19: warning[possibly-unresolved-reference] Name `method` used when possibly not defined
- rotkehlchen/externalapis/cryptocompare.py:406:19: warning[possibly-unresolved-reference] Name `method` used when possibly not defined
- Found 1571 diagnostics
+ Found 1569 diagnostics
No memory usage changes detected ✅

@github-actions
Copy link
Contributor

ecosystem-analyzer results

Lint rule Added Removed Changed
possibly-unresolved-reference 0 2 0
invalid-return-type 0 1 0
type-assertion-failure 0 1 0
Total 0 4 0

Full report with detailed diff

@sharkdp sharkdp force-pushed the david/match-exhaustiveness branch from db907cf to 7e70581 Compare July 23, 2025 16:52
pub(crate) guard: Option<Expression<'db>>,

/// A reference to the pattern of the previous match case
pub(crate) previous_predicate: Option<Box<PatternPredicate<'db>>>,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

So there are probably more efficient ways to implement this, but this linked-list approach was so unintrusive..

@sharkdp sharkdp marked this pull request as ready for review July 23, 2025 17:15
Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

This is awesome!

@sharkdp sharkdp merged commit 2a00eca into main Jul 23, 2025
37 checks passed
@sharkdp sharkdp deleted the david/match-exhaustiveness branch July 23, 2025 20:45
@charliermarsh
Copy link
Member

Pumped for this!

UnboundVariable pushed a commit to UnboundVariable/ruff that referenced this pull request Jul 24, 2025
…hlight

* 'main' of https://github.com/astral-sh/ruff:
  [ty] Minor: fix incomplete docstring (astral-sh#19534)
  [ty] Move server tests as integration tests (astral-sh#19522)
  [`ruff`] Offer fixes for `RUF039` in more cases (astral-sh#19065)
  [ty] Support `dataclasses.InitVar` (astral-sh#19527)
  [`ruff`] Fix `RUF033` breaking with named default expressions (astral-sh#19115)
  Update pre-commit hook name (astral-sh#19530)
  Bump 0.12.5 (astral-sh#19528)
  [ty] Rename type_api => ty_extensions (astral-sh#19523)
  [ty] Added support for "go to references" in ty playground. (astral-sh#19516)
  [ty] Return a tuple spec from the iterator protocol (astral-sh#19496)
  [ty] Exhaustiveness checking & reachability for `match` statements (astral-sh#19508)
  [ty] Fix narrowing and reachability of class patterns with arguments (astral-sh#19512)
AlexWaygood pushed a commit that referenced this pull request Jul 25, 2025
…19508)

## Summary

Implements proper reachability analysis and — in effect — exhaustiveness
checking for `match` statements. This allows us to check the following
code without any errors (leads to *"can implicitly return `None`"* on
`main`):

```py
from enum import Enum, auto

class Color(Enum):
    RED = auto()
    GREEN = auto()
    BLUE = auto()

def hex(color: Color) -> str:
    match color:
        case Color.RED:
            return "#ff0000"
        case Color.GREEN:
            return "#00ff00"
        case Color.BLUE:
            return "#0000ff"
```

Note that code like this already worked fine if there was a
`assert_never(color)` statement in a catch-all case, because we would
then consider that `assert_never` call terminal. But now this also works
without the wildcard case. Adding a member to the enum would still lead
to an error here, if that case would not be handled in `hex`.

What needed to happen to support this is a new way of evaluating match
pattern constraints. Previously, we would simply compare the type of the
subject expression against the patterns. For the last case here, the
subject type would still be `Color` and the value type would be
`Literal[Color.BLUE]`, so we would infer an ambiguous truthiness.

Now, before we compare the subject type against the pattern, we first
generate a union type that corresponds to the set of all values that
would have *definitely been matched* by previous patterns. Then, we
build a "narrowed" subject type by computing `subject_type &
~already_matched_type`, and compare *that* against the pattern type. For
the example here, `already_matched_type = Literal[Color.RED] |
Literal[Color.GREEN]`, and so we have a narrowed subject type of `Color
& ~(Literal[Color.RED] | Literal[Color.GREEN]) = Literal[Color.BLUE]`,
which allows us to infer a reachability of `AlwaysTrue`.

<details>

<summary>A note on negated reachability constraints</summary>

It might seem that we now perform duplicate work, because we also record
*negated* reachability constraints. But that is still important for
cases like the following (and possibly also for more realistic
scenarios):

```py
from typing import Literal

def _(x: int | str):
    match x:
        case None:
            pass # never reachable
        case _:
            y = 1

    y
```

</details>

closes astral-sh/ty#99

## Test Plan

* I verified that this solves all examples from the linked ticket (the
first example needs a PEP 695 type alias, because we don't support
legacy type aliases yet)
* Verified that the ecosystem changes are all because of removed false
positives
* Updated tests
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missed exhaustiveness checking for class unions

4 participants