We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 4 of 24
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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.