Back to Ruff

Pattern matching

crates/ty_python_semantic/resources/mdtest/conditional/match.md

0.15.1210.9 KB
Original Source

Pattern matching

toml
[environment]
python-version = "3.10"

With wildcard

py
def _(target: int):
    match target:
        case 1:
            y = 2
        case _:
            y = 3

    reveal_type(y)  # revealed: Literal[2, 3]

Without wildcard

py
def _(target: int):
    match target:
        case 1:
            y = 2
        case 2:
            y = 3

    # revealed: Literal[2, 3]
    # error: [possibly-unresolved-reference]
    reveal_type(y)

With sequence wildcard

py
from collections.abc import Sequence

def sequence_star_pattern_is_exhaustive(paths: list[int]) -> None:
    match paths:
        case [*_paths]:
            raise ValueError

    reveal_type(paths)  # revealed: Never

def sequence_star_pattern_is_not_exhaustive_for_text(paths: Sequence[str]) -> None:
    match paths:
        case [*_paths]:
            raise ValueError

    # `str`, `bytes`, and `bytearray` are subtypes of `Sequence`, but sequence
    # patterns explicitly do not match them.
    # TODO: After https://github.com/astral-sh/ty/issues/3314 is fixed, the
    # `Sequence[str] & bytes` and `Sequence[str] & bytearray` intersections
    # should simplify to `Never`.
    reveal_type(paths)  # revealed: str | (Sequence[str] & bytes) | (Sequence[str] & bytearray)

def sequence_prefix_star_pattern_is_not_catch_all(paths: Sequence[str]) -> None:
    match paths:
        case []:
            raise ValueError
        case [_first]:
            raise ValueError
        case [_first, _second, *_paths]:
            raise ValueError

    reveal_type(paths)  # revealed: Sequence[str]

Basic match

py
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]:

py
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

py
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

py
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.

py
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

py
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.

py
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

py
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

py
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.

py
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

Invalid class patterns

For class patterns, the runtime first checks that the match pattern is an instance of type, and then uses isinstance to check the match.

If the match pattern is not an instance of type, we raise a diagnostic:

py
from typing import Any
from ty_extensions import Intersection

def _(val, Valid1: type | Any, Valid2: Intersection[type, Any], Valid3: type[Any], Valid4: type[int]):
    Invalid1 = "foo"

    match val:
        # error: [invalid-match-pattern] "`Literal["foo"]` cannot be used in a class pattern because it is not a type"
        case Invalid1(): ...

    Invalid2 = int | str

    match val:
        # error: [invalid-match-pattern] "`<types.UnionType special-form 'int | str'>` cannot be used in a class pattern because it is not a type"
        case Invalid2():
            pass
        case Valid1():  # fine
            pass
        case Valid2():  # fine
            pass
        case Valid3():  # fine
            pass
        case Valid4():  # fine
            pass

We also raise a diagnostic if the class cannot be used with isinstance:

py
from typing import Any, TypedDict

def _(val):
    Invalid3 = Any

    match val:
        # TODO: this should be an `invalid-match-pattern` error
        case Invalid3(): ...

    class Invalid4(TypedDict): ...

    match val:
        # TODO: this could have the `invalid-match-pattern` error code instead.
        # error: [isinstance-against-typed-dict] "`TypedDict` class `Invalid4` cannot be used in a class pattern"
        case Invalid4(): ...

We do not raise a diagnostic for dynamic types:

py
def _(val, UnknownSymbol):
    reveal_type(UnknownSymbol)  # revealed: Unknown

    match val:
        case UnknownSymbol(): ...

We also do not raise a diagnostic if the match pattern is a non-statically known instance of type:

py
def _(val, IntOrStr: type[int | str]):
    match val:
        case IntOrStr():
            print(f"Matched as {IntOrStr}: {val!r}")