classify() over lists, a Point, and
anything elseWrite
classify(value)that usesmatchto return"empty list","singleton", or"longer list"for lists,"point"for aPoint, and"other"for anything else.
Sequence
Patterns covers matching by shape, including a star pattern
for the rest of a sequence. Then Class
Patterns shows how to test for a Point without
binding its fields. match tries cases top to
bottom, so put the narrow list shapes before the wider one.
# The shape of exercise_1.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def classify(value: object) -> str:
...If you drop the parentheses and write
case Point:, the script does not compile. Without
parentheses Point is a bare name, so the pattern
captures any value, and Python rejects the case _
after it with
SyntaxError: name capture 'Point' makes remaining patterns unreachable.
The solution writes case Point():, a class pattern
that tests the type.
# exercise_1.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def classify(value: object) -> str:
match value:
case []:
return "empty list"
case [_]:
return "singleton"
case [_, *_]:
return "longer list"
case Point():
return "point"
case _:
return "other"
print(classify([]))
#: empty list
print(classify([1]))
#: singleton
print(classify([1, 2, 3]))
#: longer list
print(classify(Point(1, 2)))
#: point
print(classify("hi"))
#: other
print(classify((1,)))
#: singletonTell lists apart by length. []
matches only an empty sequence. [_] matches a list
with exactly one element (the _ throws the element
away without a name). [_, *_] matches one or more
elements: the first _ matches the first element,
and *_ collects the rest, including an empty rest.
So [_, *_] also fits a singleton, and order
matters: [_] must come before [_, *_],
or the general pattern claims [1] first and the
“singleton” case is unreachable.
Test the type alone. Point()
matches any Point instance without binding its
fields, since classify() doesn’t need
x or y.
The string "hi" is a sequence of two characters,
and classify() still returns “other” for it: a
sequence pattern excludes str. The tuple
(1,) goes the other way. A sequence pattern tests
the shape, not the type, so a one-element tuple is a “singleton”
here. When the answer must hold for a list alone,
wrap the pattern in a class pattern:
case list([_]): first tests for a
list, then matches the one-element shape.
Rectangle without its caseAdd a
Rectangletype toexhaustive.py’sShapeunion without adding itscase. Runtyand read the error it reports atassert_never().
Exhaustive
Matching ends the match with
assert_never(). Add the new data class and include
it in the Shape union, but leave
area() alone. The type checker’s message shows the
type that survives every case above
assert_never().
@dataclass(frozen=True)
class Rectangle:
width: float
height: float
type Shape = Circle | Square | Rectangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius):
return pi * radius ** 2
case Square(side):
return side ** 2
case _:
assert_never(shape)Running ty check reports:
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> exhaustive.py:28:13
|
28 | assert_never(shape)
| ^^^^^^^^^^^^^-----^
| |
| Inferred type of argument is `Rectangle & ~Circle & ~Square`
info: `Never` and `Rectangle & ~Circle & ~Square` are not equivalent types
Once Rectangle joins the Shape
union, the type checker proves that a Rectangle
value falls through both cases and reaches
case _. assert_never() demands an
argument of type Never, meaning “this code is
unreachable.” The type checker now knows shape can
be a Rectangle at that point, so Never
and the argument’s type disagree and the checker reports an
error. That error is the check the chapter describes: the
missing case becomes a type error at check time instead of a
silent gap that shows up only when a Rectangle
reaches area() at runtime.
Rewrite
mapping_patterns.handle()to also accept a nested shape, such as{"type": "click", "at": {"x": x, "y": y}}, bindingxandyfrom the inner dictionary.
Mapping
Patterns matches a dictionary by the keys it must contain. A
value inside a mapping pattern can itself be a pattern, as Patterns
Nest shows. Put a mapping pattern inside the
"at" entry, and keep the flat click case so both
shapes still work.
# The shape of exercise_3.py
def handle(event: dict[str, object]) -> str:
...If you replace the flat click case with the nested one, a
flat click event matches no click case. It falls through to
{"type": kind}, and handle() returns
Other event: click. The exercise asks for the
nested shape in addition to the flat one, so the solution keeps
both cases.
# exercise_3.py
def handle(event: dict[str, object]) -> str:
match event:
case {"type": "click", "at": {"x": x, "y": y}}:
return f"Click at ({x}, {y})"
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "key", "key": key}:
return f"Key {key}"
case {"type": kind}:
return f"Other event: {kind}"
case unknown:
return f"Unrecognized event: {unknown}"
print(handle({"type": "click", "at": {"x": 10, "y": 20}}))
#: Click at (10, 20)
print(handle({"type": "click", "x": 10, "y": 20}))
#: Click at (10, 20)
print(handle({"type": "key", "key": "Enter"}))
#: Key EnterMatch the inner dictionary. The new
case nests a mapping pattern inside a mapping
pattern: {"at": {"x": x, "y": y}} matches when
"at" maps to a dictionary that has "x"
and "y" keys, binding both in one step.
Accept both click shapes. The nested case
and the flat {"type": "click", "x": x, "y": y} case
each describe one shape of click event, and both return the same
string. The two cases do not compete: a flat event has no
"at" key and a nested one has no top-level
"x", so each event fits only one of them.
Try the specific cases first. Order matters
for {"type": kind}, which any event with a
"type" key satisfies. match tries
cases top to bottom and stops at the first one that fits, so
{"type": kind} sits after the specific click and
key cases.
Webhook channel added to the unionAdd a
Webhookchannel tonotifications_match.py: a data class with aurlfield, added to theNotificationunion. Runtybefore adding itscasetorender()andcost(), and read the errors. Then add both cases and confirmtypasses.
The
Match Version ends both render() and
cost() with assert_never(). Add the
Webhook data class to the Notification
union first and run the type checker before touching either
function. Each function that matches on the union reports its
own error until it gains a case.
# The shape of exercise_4.py
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Email:
subject: str
@dataclass(frozen=True)
class Sms:
body: str
@dataclass(frozen=True)
class Push:
title: str
@dataclass(frozen=True)
class Webhook:
url: str
type Notification = Email | Sms | Push | Webhook
def render(note: Notification, recipient: str) -> str:
...
def cost(note: Notification) -> float:
...# exercise_4.py
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Email:
subject: str
@dataclass(frozen=True)
class Sms:
body: str
@dataclass(frozen=True)
class Push:
title: str
@dataclass(frozen=True)
class Webhook:
url: str
type Notification = Email | Sms | Push | Webhook
def render(note: Notification, recipient: str) -> str:
match note:
case Email(subject):
return f"Email to {recipient}: {subject}"
case Sms(body):
return f"SMS to {recipient}: {body}"
case Push(title):
return f"Push to {recipient}: {title}"
case Webhook(url):
return f"POST for {recipient} to {url}"
case _:
assert_never(note)
def cost(note: Notification) -> float:
match note:
case Email():
return 0.001
case Sms():
return 0.02
case Push():
return 0.0005
case Webhook():
return 0.0
case _:
assert_never(note)
hook = Webhook("https://example.com/hook")
print(render(hook, "Dana"))
#: POST for Dana to https://example.com/hook
print(cost(hook))
#: 0.0Add Webhook to the union and run ty
before adding either case. The checker reports two
diagnostics, one per function that matches on the union:
error[type-assertion-failure]: Argument does not have asserted type `Never`
--> notifications_match.py:32:13
|
32 | assert_never(note)
| ^^^^^^^^^^^^^----^
| |
| Inferred type of argument is `Webhook & ~Email & ~Sms & ~Push`
info: `Never` and `Webhook & ~Email & ~Sms & ~Push` are not equivalent types
The fence shows the first diagnostic, in
render(). The second is identical, at line 43 in
cost().
assert_never() declares its parameter as
Never, the type with no values, so the call checks
only when the cases above it have eliminated every member of the
union. The inferred type spells out what survives those cases: a
Webhook that is none of the three handled types. A
Webhook can still reach the
assert_never() call, so the check fails. Two
diagnostics for one new channel is the cost the chapter
describes: adding a type touches every operation.
Rewrite
guards.py’squadrant()so it handles the third and fourth quadrants too. Then write it a second time with onecaseper sign combination, using|alternations and no guards, and say which version reads better.
Guards
adds an if condition to a case. For
the second version, change the subject of the match
to a tuple of derived values. Then write literal patterns for
each combination, and use | from Alternatives
and Capture to join the axis cases.
# The shape of exercise_5.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def quadrant(p: Point) -> str:
...# The shape of exercise_5_signs.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def sign(n: int) -> int:
...
def quadrant(p: Point) -> str:
...# exercise_5.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def quadrant(p: Point) -> str:
match p:
case Point(0, 0):
return "Origin"
case Point(x, y) if x > 0 and y > 0:
return "First quadrant"
case Point(x, y) if x < 0 and y > 0:
return "Second quadrant"
case Point(x, y) if x < 0 and y < 0:
return "Third quadrant"
case Point(x, y) if x > 0 and y < 0:
return "Fourth quadrant"
case _:
return "On an axis"
print(quadrant(Point(-3, -4)), quadrant(Point(3, -4)))
#: Third quadrant Fourth quadrant
print(quadrant(Point(0, 7)))
#: On an axisThe second version replaces every guard with a literal pattern, by matching on the pair of signs rather than on the point:
# exercise_5_signs.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def sign(n: int) -> int:
return (n > 0) - (n < 0)
def quadrant(p: Point) -> str:
match sign(p.x), sign(p.y):
case 0, 0:
return "Origin"
case 1, 1:
return "First quadrant"
case -1, 1:
return "Second quadrant"
case -1, -1:
return "Third quadrant"
case 1, -1:
return "Fourth quadrant"
case (0, _) | (_, 0):
return "On an axis"
case _:
return "unreachable"
print(quadrant(Point(-3, -4)), quadrant(Point(3, -4)))
#: Third quadrant Fourth quadrant
print(quadrant(Point(0, 7)))
#: On an axisThe second version reads better. A guard hides the shape of
the dispatch: you must read four nearly identical
if clauses one at a time to see that they enumerate
sign combinations. Once the subject is
sign(p.x), sign(p.y), the cases are literals in a
two-column table, and a missing combination is visible at a
glance. The | alternation then handles both axis
cases in one line, which no guard arrangement does as
briefly.
The cost is the sign() helper and one extra
layer of indirection: the match no longer mentions
Point. That trade is usually worth it when the
guards all test the same handful of derived facts, and not worth
it when each guard asks a different question.
Give every path a return. The final
case _ is unreachable, since the six cases above it
cover all nine pairs of signs. The type checker sees only that
sign() returns an int, so without that
case it reports that quadrant() can return
None.
Give
value_patterns.py’sSignala third member, and writeact()so that it compares against a module-levelFALLBACK: Final[Signal]. Run it and confirm that the constant captures instead of comparing. Then fix it two ways, with a dotted name and with a guard.
A
Bare Name Captures, a Dotted Name Compares explains why a
module-level constant in a case binds instead of
comparing. For the first fix, put the constant in a namespace so
the pattern is a dotted name. For the second, bind any value and
compare it to the constant in a guard, where the name is an
ordinary expression.
# The shape of ch13_fallback_capture.py
from enum import Enum
from typing import Final
class Signal(Enum):
STOP = "stop"
GO = "go"
CAUTION = "caution"
FALLBACK: Final[Signal] = Signal.CAUTION
class Defaults:
FALLBACK: Final[Signal] = Signal.CAUTION
def act(s: Signal) -> str:
...
def dotted(s: Signal) -> str:
...
def guarded(s: Signal) -> str:
...# ch13_fallback_capture.py
from enum import Enum
from typing import Final
class Signal(Enum):
STOP = "stop"
GO = "go"
CAUTION = "caution"
FALLBACK: Final[Signal] = Signal.CAUTION
class Defaults:
FALLBACK: Final[Signal] = Signal.CAUTION
def act(s: Signal) -> str:
match s:
case Signal.GO:
return "accelerate"
case FALLBACK:
return f"fallback, FALLBACK is now {FALLBACK}"
return "unreachable"
def dotted(s: Signal) -> str:
match s:
case Signal.GO:
return "accelerate"
case Defaults.FALLBACK:
return "fallback"
case _:
return "brake"
def guarded(s: Signal) -> str:
match s:
case Signal.GO:
return "accelerate"
case other if other is FALLBACK:
return "fallback"
case _:
return "brake"
print(act(Signal.STOP))
#: fallback, FALLBACK is now Signal.STOP
print(FALLBACK)
#: Signal.CAUTION
print(dotted(Signal.STOP), dotted(Signal.CAUTION))
#: brake fallback
print(guarded(Signal.STOP), guarded(Signal.CAUTION))
#: brake fallbackDemonstrate the accidental capture.
act() answers “fallback” for
Signal.STOP, which is not the fallback value.
case FALLBACK: is a bare name, so it captures: it
matches Signal.STOP, binds it to a local named
FALLBACK inside act(), and compares
nothing. The module-level constant still holds
Signal.CAUTION afterward, so the mistake is easy to
miss. Python accepts case FALLBACK: only because it
is the last case. Another case after it fails to compile.
Compare through a dotted name. The first fix
gives the constant a dotted name by putting it in a namespace.
Defaults.FALLBACK is a value pattern, so
dotted() compares against it and answers “brake”
for Signal.STOP. Any dotted name works, including
Signal.CAUTION. In a program the constant would
live in Defaults alone, one definition for every
use. The listing keeps the module-level copy because
act() and guarded() need the bare
name.
Compare in a guard. The second fix keeps the
bare constant and moves the comparison into a guard, where
FALLBACK is an ordinary expression rather than a
pattern. case other if other is FALLBACK: is more
verbose than the dotted name, but it is what you want when the
test is more than equality.