Skip to content

← Pattern Matching, f-Strings and Newer Syntax step 13 of 23

Hard End-to-End

Let mypy Catch a Missing Case with assert_never

Your app logs four kinds of event, and describe turns each one into a line of text. Next month someone adds a fifth kind to the Event union and forgets describe. If describe ends with case _: return "unknown", the new event logs as "unknown", and mypy has nothing to report. You want mypy to point at describe instead.

from typing import assert_never


def describe(event: Event) -> str:
    match event:
        case Created(entity):
            return f"created {entity}"
        # ... one case for each of the other three classes ...
        case _ as unhandled:
            assert_never(unhandled)

How the check works

mypy narrows the type of event as it reads the cases. After each case, it removes the class that case handled from the types event can still have. Once all four classes have a case, no type is left, and mypy gives unhandled the type Never, the type with no values.

assert_never from typing accepts only an argument of type Never. If a class has no case, unhandled still has that class's type, and mypy reports an error at the call:

error: Argument 1 to "assert_never" has incompatible type "Archived"; expected "Never"

At runtime, assert_never raises AssertionError if Python ever reaches it.

mypy checks against the Event union, so a new class counts only after you add it to that union.

The diagram shows each case narrowing event until nothing is left:

Loading visualization…

What to write

parse(raw) turns a dictionary into an event. The fields hold strings:

"kind" Fields Returns
"created" entity Created(entity)
"updated" entity, field Updated(entity, field)
"deleted" entity Deleted(entity)
"archived" entity, reason Archived(entity, reason)

Extra keys and empty strings are fine. For a missing field, a field that is not a string, or an unknown kind, raise ValueError(f"unknown event: {raw!r}").

describe(event) returns "created {entity}", "updated {entity}.{field}", "deleted {entity}" or "archived {entity} ({reason})", with the fields filled in. Give each class a case, and end with the assert_never case.

severity(level) maps Level.DEBUG, INFO, WARNING and ERROR to 10, 20, 30 and 40. Match the members with dotted names, and end with assert_never.

solve(events, probe_unknown) handles each dictionary in order:

  1. Read "level". If the key is missing, use "info".
  2. The level must be a string that Level accepts: "debug", "info", "warning" or "error". So "DEBUG" and an explicit None are both invalid.
  3. Parse the event and add f"{severity(level)}|{describe(event)}".
  4. For an invalid level or event, add "invalid" and go on to the next.
  5. After all events, if probe_unknown is true, add the result of probe_backstop().

A created event for "order" with no level gives "20|created order". With no events, solve returns [], or ["assert_never"] when probe_unknown is true.

probe_backstop() shows the runtime side. mypy rejects describe(Rogue("ghost")), because Rogue is not in Event. The starter's erased: Callable[..., str] accepts any arguments, so call erased(Rogue("ghost")) and return "assert_never" when it raises AssertionError.

Going further

Run mypy with --enable-error-code exhaustive-match, and it reports any match that leaves a type unhandled, even one with no assert_never case.