Back to Ruff

Pattern matching

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

0.15.1714.4 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

    # Exact sequence alternatives remain as negative protocol constraints.
    # revealed: (Sequence[str] & ~<Protocol with members '__len__'> & ~<Protocol with members '__getitem__', '__len__'>) | str | (Sequence[str] & bytes) | (Sequence[str] & bytearray)
    reveal_type(paths)

def exact_sequence_pattern_is_exhaustive(value: tuple[int, str]) -> int:
    match value:
        case int(), str():
            return 1

def refutable_exact_sequence_pattern_is_not_exhaustive(value: tuple[int]) -> int:  # error: [invalid-return-type]
    match value:
        case [int(real=0)]:
            return 1

def guarded_exact_sequence_pattern_is_not_exhaustive(value: tuple[int, str], flag: bool) -> int:  # error: [invalid-return-type]
    match value:
        case [int(), str()] if flag:
            return 1

def guarded_then_unguarded_exact_sequence_patterns_are_exhaustive(value: tuple[int, str], flag: bool) -> int:
    match value:
        case [int(), str()] if flag:
            return 1
        case [int(), str()]:
            return 2

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]

Dynamic class

A dynamically typed class pattern is not known to match every subject, so later cases remain reachable.

py
from typing import Any

DynamicClass: Any = int

def _(target: int | str):
    match target:
        case DynamicClass():
            reveal_type(target)  # revealed: (int & Any) | (str & Any)
            y = 1
        case _:
            reveal_type(target)  # revealed: (int & Any) | (str & Any)
            y = 2

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

Subclass-of type

A class pattern whose class expression has type type[Base] is not guaranteed to match a Base subject. PatternClass can evaluate to any subclass of Base, and a Base instance need not be an instance of that subclass. The PatternClass arm must therefore not be considered guaranteed to match, and the fallback arm remains reachable.

py
class Base: ...
class Derived(Base): ...

PatternClass: type[Base] = Derived

def _(target: Base):
    match target:
        case PatternClass():
            reveal_type(target)  # revealed: Base
            y = 1
        case _:
            reveal_type(target)  # revealed: Base
            y = 2

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

collections.abc.Callable

py
from collections import abc

def _(subj: abc.Callable[..., str]) -> None:
    y = 1

    match subj:
        case abc.Callable():
            y = 2
        case _:
            y = 3

    reveal_type(y)  # revealed: Literal[2]

def _(subj: None) -> None:
    y = 1

    match subj:
        case abc.Callable():
            y = 2

    reveal_type(y)  # revealed: Literal[1]

def _(subj: int | abc.Callable[..., str]) -> None:
    y = 1

    match subj:
        case abc.Callable():
            y = 2
        case _:
            y = 3

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

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]

Matching on enum value patterns in invalid code

This is a regression test for https://github.com/astral-sh/ty/issues/3481.

toml
[environment]
python-version = "3.14"
py
from enum import Enum
from typing import TypeVar

def f(x: T): ...
def g(x: T): ...

f()  # error: [missing-argument] "No argument provided for required parameter `x`"
g()  # error: [missing-argument]

class C(Enum):
    a = 1
    b = 2

match m:  # error: [unresolved-reference] "Name `m` used when not defined"
    case C.a:
        _()  # error: [unresolved-reference] "Name `_` used when not defined"
    case _:
        _()  # error: [unresolved-reference]

T = TypeVar

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}")