[ty] Reachability and narrowing for enum methods (#21130)

## Summary

Adds proper type narrowing and reachability analysis for matching on
non-inferable type variables bound to enums. For example:

```py
from enum import Enum

class Answer(Enum):
    NO = 0
    YES = 1

    def is_yes(self) -> bool:  # no error here!
        match self:
            case Answer.YES:
                return True
            case Answer.NO:
                return False
```

closes https://github.com/astral-sh/ty/issues/1404

## Test Plan

Added regression tests
This commit is contained in:
David Peter 2025-10-30 15:38:57 +01:00 committed by GitHub
parent 1b0ee4677e
commit e55bc943e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 121 additions and 5 deletions

View file

@ -379,3 +379,22 @@ def as_pattern_non_exhaustive(subject: int | str):
# this diagnostic is correct: the inferred type of `subject` is `str`
assert_never(subject) # error: [type-assertion-failure]
```
## Exhaustiveness checking for methods of enums
```py
from enum import Enum
class Answer(Enum):
YES = "yes"
NO = "no"
def is_yes(self) -> bool:
reveal_type(self) # revealed: Self@is_yes
match self:
case Answer.YES:
return True
case Answer.NO:
return False
```

View file

@ -252,3 +252,51 @@ match x:
reveal_type(x) # revealed: object
```
## Narrowing on `Self` in `match` statements
When performing narrowing on `self` inside methods on enums, we take into account that `Self` might
refer to a subtype of the enum class, like `Literal[Answer.YES]`. This is why we do not simplify
`Self & ~Literal[Answer.YES]` to `Literal[Answer.NO, Answer.MAYBE]`. Otherwise, we wouldn't be able
to return `self` in the `assert_yes` method below:
```py
from enum import Enum
from typing_extensions import Self, assert_never
class Answer(Enum):
NO = 0
YES = 1
MAYBE = 2
def is_yes(self) -> bool:
reveal_type(self) # revealed: Self@is_yes
match self:
case Answer.YES:
reveal_type(self) # revealed: Self@is_yes
return True
case Answer.NO | Answer.MAYBE:
reveal_type(self) # revealed: Self@is_yes & ~Literal[Answer.YES]
return False
case _:
assert_never(self) # no error
def assert_yes(self) -> Self:
reveal_type(self) # revealed: Self@assert_yes
match self:
case Answer.YES:
reveal_type(self) # revealed: Self@assert_yes
return self
case _:
reveal_type(self) # revealed: Self@assert_yes & ~Literal[Answer.YES]
raise ValueError("Answer is not YES")
Answer.YES.is_yes()
try:
reveal_type(Answer.MAYBE.assert_yes()) # revealed: Literal[Answer.MAYBE]
except ValueError:
pass
```