| name | branching-modeled-state-with-match |
| description | Use when writing Python that branches on a value carrying a modeled state — a union of dataclass variants, a Literal-tagged kind/status/type field, or any state-machine state — to choose behavior or a return value, or when adding a variant to such a union. Also when reaching for an if/elif or isinstance chain on a tag field, a hand-rolled exhaustiveness helper, or a dict lookup to consume a tagged union. |
Branching Modeled State With match
Overview
When a value models state as a union of variants, branch on it with a match statement and end with a catch-all that calls stdlib typing.assert_never. A forgotten variant then becomes a pyright failure that points at the exact match, not a silent runtime fallthrough.
This skill is the consume side of a modeled type. REQUIRED SUB-SKILL: Use precise-type-modeling to model the union in the first place (frozen dataclass variants or a Literal discriminant, all states represented, no Any/dict[str, Any]).
The recipe (NEW and existing code)
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Idle: ...
@dataclass(frozen=True)
class Loading: ...
@dataclass(frozen=True)
class Ready:
value: str
@dataclass(frozen=True)
class Failed:
error: str
FetchState = Idle | Loading | Ready | Failed
def label(state: FetchState) -> str:
match state:
case Idle():
return "待機中"
case Loading():
return "読み込み中"
case Ready(value=value):
return value
case Failed(error=error):
return f"失敗({error})"
case _ as unreachable:
assert_never(unreachable)
Add a variant Cancelled to the union and unreachable is no longer Never — assert_never(unreachable) fails pyright, pointing at this match. Add the case, the check goes green. assert_never also raises at runtime, guarding malformed data.
If the union is Literal-tagged instead (kind: Literal["idle", ...] on one class), match on the tag the same way: match state.kind: case "idle": ..., same assert_never catch-all.
No lookup-map shortcut in Python
TypeScript's satisfies Record<Kind, string> has no Python equivalent: a dict annotated dict[Kind, str] typechecks with keys missing, so a lookup map silently loses exhaustiveness. Use match even for pure constant mappings. Never paper over a dict with .get(kind, fallback).
Why not the alternatives
| Anti-pattern | Why it fails |
|---|
if isinstance(s, X): … elif isinstance(s, Y): … + trailing return fallback | The fallback silently swallows a new variant — no type error. if/elif chains give no exhaustiveness. |
Hand-rolled assert_never / exhaustive_check / unreachable() helper | The stdlib primitive exists (typing.assert_never, 3.11+). A local clone hides from pyright and readers. |
dict map keyed by kind | pyright never verifies the dict covers every variant, and the map can't reach per-variant fields. |
case _: return "" / case _: return fallback | Defeats the whole point — a forgotten variant typechecks and ships. |
case _: raise ValueError(...) | Runtime-only. pyright stays green when a variant is missing. Use assert_never. |
Common mistakes
- Adding a
case _ that returns a fallback "to be safe" — it disables exhaustiveness. The catch-all must call assert_never.
- Writing your own never-check helper. Import
assert_never from typing and call it inline.
- Relying only on pyright's
reportMatchNotExhaustive with no catch-all — keep the assert_never arm; it documents intent and raises on malformed runtime data.
- Matching on a plain
str field instead of a modeled union. Model first (precise-type-modeling), then match.
Red Flags — STOP
- About to write
if isinstance(state, …) or if state.kind == … for a union → use match.
- About to define or import a homemade
assert_never → from typing import assert_never.
- About to add a
case _ that returns a value or raises ValueError → only assert_never belongs there.
- A trailing
return fallback after the branches → a new variant will be swallowed silently.