Skip to content

← The Type System as a Design Tool step 4 of 24

Medium Primitives

assert_never and the event that was never handled

One line of code turns “we shipped a new payment method and the handler fell through to the default branch” from a Sunday incident into a red CI run.

That line is assert_never.

typing.Never is the bottom type: no value has it. When you narrow a union down to nothing, the checker infers Never for the remaining value. So a call to a function declared def assert_never(arg: Never) -> Never only type-checks if the argument is provably impossible — and if it is possible, mypy tells you precisely which case you forgot:

error: Argument 1 to "assert_never" has incompatible type "Literal['refunded']";
       expected "Never"

That message names the missed variant. No test can produce a better diagnostic, and no test runs at all if nobody wrote it. At runtime assert_never raises AssertionError, so a value that sneaks past the checker still fails loudly instead of silently returning None.

The task

type Event = Literal["created", "updated", "deleted"]

def parse_event(raw: str) -> Event:
def describe(event: Event, name: str) -> str:
def solve(events: list[str], name: str) -> list[str]:

describe returns one sentence per variant:

event sentence
created "<name> was created."
updated "<name> was updated."
deleted "<name> was deleted permanently."

and closes its match with case _: assert_never(event).

parse_event is the other half of the lesson and the part people skip. A Literal type is a promise about a value, and values arriving from a queue, an HTTP body or a database column are str. Something has to narrow str down to Event, and that something is a real function with a real failure mode: parse_event raises ValueError with the message "unknown event: <raw>" for anything not in EVENTS.

solve runs each raw event through parse_event then describe, collecting the sentences; when parse_event raises, it appends the exception’s message instead. So an unrecognised event produces "unknown event: archived" in the output list.

The comparison is exact and case-sensitive: "Created" is not "created".

Why the loop in parse_event type-checks

EVENTS: Final[tuple[Event, ...]] = ("created", "updated", "deleted")

for known in EVENTS:
    if raw == known:
        return known

known is typed Event because the tuple is, so returning it satisfies -> Event with no cast and no # type: ignore. Compare with the version people write first — if raw in EVENTS: return raw — which does not type-check under mypy, because mypy does not narrow x in (...) to a Literal. (pyright does. This is one of the divergences worth knowing about: “my code type-checks” is checker-relative.)

Seeing the mechanism fire

The interesting half of this exercise is what you cannot see from a passing run. Delete one case arm from describe and re-run the checker. You should get the error quoted at the top, naming the arm you deleted. Do that before you submit — the point is not that assert_never is quiet when you are right, it is that it is loud, specific and early when you are wrong.

Note that this only works because the final case _ is genuinely unreachable. If you write case _: return "unknown" instead, you have re-created the silent fallthrough by hand, and mypy --strict will never mention it again.

Loading visualization…