Skip to content

← Modern Syntax and Modernisation step 14 of 18

Medium End-to-End

Guards, OR patterns, AS patterns and the binding rules

Guards are what stop a match degenerating into “destructure here, test the field twenty lines later”. The condition and the destructuring live in the same line, so it is not possible to test a field you did not extract from this shape.

Three constructs, three rules

Guards. case Http(status=s) if s >= 500: — the pattern binds first, then the guard runs with the bindings visible. If the guard is false, control falls through to the next case; it does not leave the match. This is the single most useful thing to know about guards, and the thing most people get wrong when reading unfamiliar code.

Because a guarded pattern is refutable, case _ if debug: may legally appear before other cases — the “irrefutable pattern must be last” rule does not apply to it.

OR patterns. case 404 | 410:. Alternatives are tried left to right, and the first match wins. The hard constraint: every alternative must bind the same names. case [x] | [x, y]: is a SyntaxError, not a runtime surprise. That is the language enforcing statically the property that mypy’s possibly-undefined error code exists to catch everywhere else — a rare case of the grammar doing the type checker’s job.

AS patterns. pattern as name binds the subject of a subpattern:

case Http(status=(404 | 410) as status, path=path):

captures whichever of 404/410 matched, so the handler can report it. Without the AS you would have to duplicate the case or re-read the attribute.

What you are building

An event router.

def parse(raw: Mapping[str, object]) -> Event      # mapping patterns
def route(event: Event) -> str                     # class patterns + guards
def solve(events: list[dict[str, object]]) -> list[str]

parse turns a raw mapping into Http(status, path), Timer(seconds) or User(name, age), raising ValueError for anything else. solve catches that and records "invalid".

route, in order:

case result
Http with status >= 500 "alert:{path}"
Http with status 404 or 410 "gone:{status}:{path}"
Http whose path starts /admin "forbidden"
any other Http "ok:{path}"
Timer with seconds > 60 "slow-timer:{seconds}"
any other Timer "timer"
User aged 18 or over "adult:{name}"
any other User "minor:{name}"

The tests deliberately include a 200 on /admin/panel: the first guard fails, the OR pattern does not match, and the third case’s guard succeeds. If you read “a failing guard exits the match” you will produce "ok:/admin/panel" and leave an authorisation hole.

The typing payoff

parse takes Mapping[str, object] — the honest type of decoded JSON — and returns a real union. The mapping pattern case {"kind": "http", "status": int() as status, "path": str() as path}: does the narrowing and the validation and the extraction in one expression, with no cast, no assert isinstance, and no # type: ignore. That is the best argument for match in a strictly-typed codebase: it is the only construct that narrows several fields of an untyped mapping at once.