Skip to content

← Modern Syntax and Modernisation step 13 of 18

Hard End-to-End

Exhaustive matching with assert_never

This is the highest-leverage typing technique in the whole course. Done once, per union, it converts “somebody added a variant and forgot to handle it” from a production incident into a CI failure.

The mechanism

from typing import assert_never

def describe(event: Event) -> str:
    match event:
        case Created(entity):
            return f"created {entity}"
        case Updated(entity, field):
            return f"updated {entity}.{field}"
        case _ as unhandled:
            assert_never(unhandled)

assert_never is declared def assert_never(arg: Never, /) -> Never. If the checker can prove every variant is handled, the subject’s type at the final case is Never and the call type-checks. Add a third variant to Event and the subject is Deleted, which is not Never — a hard error, in this file, pointing at this line, in every checker that implements the typing spec.

Two things it is not:

  • It is not a runtime check you rely on. It raises AssertionError when reached, which is a backstop for code that got past the checker via Any, a cast, or an untyped caller.
  • It is not free. It requires a closed subject type — an Enum, a Literal[...], or a tagged union of dataclasses. Exhaustiveness over int or str is not a thing.

The alternative is mypy’s opt-in exhaustive-match error code, which reports a non-exhaustive match with no code change at all. It is mypy-only; the assert_never idiom works everywhere and documents the intent in the source. Use both.

What defeats it

        case _:
            return "unknown"          # exhaustiveness: gone

A wildcard that returns makes the match total, so the checker has nothing to complain about — forever. Same for a trailing else. And remember the previous item’s trap: case PENDING: is a capture, so it matches everything, which also makes the match total and every later case dead.

What you are building

type Event = Created | Updated | Deleted | Archived

Four frozen dataclasses (given), plus a Level StrEnum with four members.

  • severity(level: Level) -> int — 10/20/30/40, exhaustively, ending in assert_never.
  • describe(event: Event) -> str"created {entity}", "updated {entity}.{field}", "deleted {entity}", "archived {entity} ({reason})", ending in assert_never.
  • parse(raw) -> Event — mapping patterns; ValueError on anything else.
  • probe_backstop() -> str — assign describe to a Callable[..., str], call it with a Rogue instance (a fifth dataclass that is deliberately not in the union), and return "assert_never" if that raises AssertionError.
  • solve(events, probe_unknown) — for each event, "{severity}|{describe}", or "invalid" if it does not parse or its "level" is not a Level. The default level is "info". When probe_unknown is true, append the probe result.

The probe is not a stunt. Callable[..., R] erasing the parameter list is a real hole in every codebase that stores handlers in a registry, and the AssertionError is what tells you which one leaked through — instead of a plausible-looking "unknown" string travelling downstream.

Loading visualization…