## Summary If we match on an `TestEnum | None`, then when adding a case like `~Literal[TestEnum.FOO]` (i.e., after `if value == TestEnum.FOO: return`), we'd distribute `Literal[TestEnum.BAR]` on the entire builder, creating `None & Literal[TestEnum.BAR]` which simplified to `Never`. Instead, we should only expand to the remaining members for pieces of the intersection that contain the enum. Now, `(TestEnum | None) & ~Literal[TestEnum.FOO] & ~Literal[TestEnum.BAR]` correctly simplifies to `None` instead of `Never`. Closes https://github.com/astral-sh/ty/issues/2260.
7.9 KiB
Pattern matching
[environment]
python-version = "3.10"
With wildcard
def _(target: int):
match target:
case 1:
y = 2
case _:
y = 3
reveal_type(y) # revealed: Literal[2, 3]
Without wildcard
def _(target: int):
match target:
case 1:
y = 2
case 2:
y = 3
# revealed: Literal[2, 3]
# error: [possibly-unresolved-reference]
reveal_type(y)
Basic match
def _(target: int):
y = 1
y = 2
match target:
case 1:
y = 3
case 2:
y = 4
reveal_type(y) # revealed: Literal[2, 3, 4]
Value match
A value pattern matches based on equality: the first case branch here will be taken if subject
is equal to 2, even if subject is not an instance of int. We can't know whether C here has a
custom __eq__ implementation that might cause it to compare equal to 2, so we have to consider
the possibility that the case branch might be taken even though the type C is disjoint from the
type Literal[2].
This leads us to infer Literal[1, 3] as the type of y after the match statement, rather than
Literal[1]:
from typing import final
@final
class C:
pass
def _(subject: C):
y = 1
match subject:
case 2:
y = 3
reveal_type(y) # revealed: Literal[1, 3]
Class match
A case branch with a class pattern is taken if the subject is an instance of the given class, and
all subpatterns in the class pattern match.
Without arguments
from typing import final
class Foo:
pass
class FooSub(Foo):
pass
class Bar:
pass
@final
class Baz:
pass
def _(target: FooSub):
y = 1
match target:
case Baz():
y = 2
case Foo():
y = 3
case Bar():
y = 4
reveal_type(y) # revealed: Literal[3]
def _(target: FooSub):
y = 1
match target:
case Baz():
y = 2
case Bar():
y = 3
case Foo():
y = 4
reveal_type(y) # revealed: Literal[3, 4]
def _(target: FooSub | str):
y = 1
match target:
case Baz():
y = 2
case Foo():
y = 3
case Bar():
y = 4
reveal_type(y) # revealed: Literal[1, 3, 4]
With arguments
from typing_extensions import assert_never
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
class Other: ...
def _(target: Point):
y = 1
match target:
case Point(0, 0):
y = 2
case Point(x=0, y=1):
y = 3
case Point(x=1, y=0):
y = 4
reveal_type(y) # revealed: Literal[1, 2, 3, 4]
def _(target: Point):
match target:
case Point(x, y): # irrefutable sub-patterns
pass
case _:
assert_never(target)
def _(target: Point | Other):
match target:
case Point(0, 0):
reveal_type(target) # revealed: Point
case Point(x=0, y=1):
reveal_type(target) # revealed: Point
case Point(x=1, y=0):
reveal_type(target) # revealed: Point
case Other():
reveal_type(target) # revealed: Other
Singleton match
Singleton patterns are matched based on identity, not equality comparisons or isinstance() checks.
from typing import Literal
def _(target: Literal[True, False]):
y = 1
match target:
case True:
y = 2
case False:
y = 3
case None:
y = 4
reveal_type(y) # revealed: Literal[2, 3]
def _(target: bool):
y = 1
match target:
case True:
y = 2
case False:
y = 3
case None:
y = 4
reveal_type(y) # revealed: Literal[2, 3]
def _(target: None):
y = 1
match target:
case True:
y = 2
case False:
y = 3
case None:
y = 4
reveal_type(y) # revealed: Literal[4]
def _(target: None | Literal[True]):
y = 1
match target:
case True:
y = 2
case False:
y = 3
case None:
y = 4
reveal_type(y) # revealed: Literal[2, 4]
# bool is an int subclass
def _(target: int):
y = 1
match target:
case True:
y = 2
case False:
y = 3
case None:
y = 4
reveal_type(y) # revealed: Literal[1, 2, 3]
def _(target: str):
y = 1
match target:
case True:
y = 2
case False:
y = 3
case None:
y = 4
reveal_type(y) # revealed: Literal[1]
Matching on enums
from enum import Enum
class Answer(Enum):
NO = 0
YES = 1
def _(answer: Answer):
y = 0
match answer:
case Answer.YES:
reveal_type(answer) # revealed: Literal[Answer.YES]
y = 1
case Answer.NO:
reveal_type(answer) # revealed: Literal[Answer.NO]
y = 2
reveal_type(y) # revealed: Literal[1, 2]
Or match
A | pattern matches if any of the subpatterns match.
from typing import Literal, final
def _(target: Literal["foo", "baz"]):
y = 1
match target:
case "foo" | "bar":
y = 2
case "baz":
y = 3
reveal_type(y) # revealed: Literal[2, 3]
def _(target: None):
y = 1
match target:
case None | 3:
y = 2
case "foo" | 4 | True:
y = 3
reveal_type(y) # revealed: Literal[2]
@final
class Baz:
pass
def _(target: int | None | float):
y = 1
match target:
case None | 3:
y = 2
case Baz():
y = 3
reveal_type(y) # revealed: Literal[1, 2]
class Foo: ...
def _(target: None | Foo):
y = 1
match target:
case Baz() | True | False:
y = 2
case int():
y = 3
reveal_type(y) # revealed: Literal[1, 3]
as patterns
def _(target: int | str):
y = 1
match target:
case 1 as x:
y = 2
reveal_type(x) # revealed: @Todo(`match` pattern definition types)
case "foo" as x:
y = 3
reveal_type(x) # revealed: @Todo(`match` pattern definition types)
case _:
y = 4
reveal_type(y) # revealed: Literal[2, 3, 4]
Guard with object that implements __bool__ incorrectly
class NotBoolable:
__bool__: int = 3
def _(target: int, flag: NotBoolable):
y = 1
match target:
# error: [unsupported-bool-conversion] "Boolean conversion is not supported for type `NotBoolable`"
case 1 if flag:
y = 2
case 2:
y = 3
reveal_type(y) # revealed: Literal[1, 2, 3]
Matching on enum | None without covering None
When matching on a union of an enum and None, code after the match should still be reachable if None is not covered by any case, even when all enum members are covered.
from enum import Enum
class Answer(Enum):
YES = 1
NO = 2
def _(answer: Answer | None):
y = 0
match answer:
case Answer.YES:
y = 1
case Answer.NO:
y = 2
# The match is not exhaustive because None is not covered,
# so y could still be 0
reveal_type(y) # revealed: Literal[0, 1, 2]
def _(answer: Answer | None):
match answer:
case Answer.YES:
return 1
case Answer.NO:
return 2
# Code here is reachable because None is not covered
reveal_type(answer) # revealed: None
return 3
class Foo: ...
def _(answer: Answer | None):
match answer:
case Answer.YES:
return
case Answer.NO:
return
# New assignments after the match should not be `Never`
x = Foo()
reveal_type(x) # revealed: Foo