Skip to content

← Precise Type Hints with Literals and Generics step 5 of 26

Medium Primitives

Make mypy Report an Unhandled Event with assert_never

An audit log receives events as strings, and you turn each one into a sentence. Next month a teammate adds "archived" to the Event type and forgets to write its sentence. You want mypy to find the missing case before a user does.

assert solve(["created", "archived", "deleted"], "order-7") == [
    "order-7 was created.",
    "unknown event: archived",
    "order-7 was deleted permanently.",
]

The starter is missing a case

The starter's describe handles "created" and "updated", then ends with case _: assert_never(event). mypy reports three errors for the starter, and the one in describe reads:

Argument 1 to "assert_never" has incompatible type "Literal['deleted']"; expected "Never"  [arg-type]

Never is the type with no values. Each case removes one value from the type of event for the cases below it. When every value has its own case, nothing is left, so at case _ the type of event is Never. assert_never accepts only Never, so the call passes mypy. Here "deleted" has no case yet, so mypy names it.

A match that handles every possible value is called exhaustive. With assert_never at the end, mypy reports the same error whenever someone adds a value to Event and forgets its case. If a value does reach assert_never at runtime, it raises AssertionError.

What to write

The starter defines the type and a tuple of the valid values:

type Event = Literal["created", "updated", "deleted"]
EVENTS: Final[tuple[Event, ...]] = ("created", "updated", "deleted")
  • parse_event(raw: str) -> Event returns raw as an Event when it is exactly one of the strings in EVENTS. Otherwise it raises ValueError(f"unknown event: {raw}").
  • A Literal type checks nothing at runtime, so parse_event is where you check the input. Loop over EVENTS and return the item that equals raw. Its type is already Event, so you need no cast.
  • describe(event: Event, name: str) -> str uses match to return these sentences, with name inserted unchanged:
Event Result
created "<name> was created."
updated "<name> was updated."
deleted "<name> was deleted permanently."
  • Keep case _: assert_never(event) as the last case. A case _ that returns a default string also passes mypy, but it hides a missing case instead of reporting it.
  • solve(events: list[str], name: str) -> list[str] parses and describes each event in order. When parse_event raises ValueError, append the error message and go on to the next event. Keep duplicates.

Edge cases

  • Matching is exact. Do not trim spaces or change case, so "Created", " created" and "" are all unknown.
  • For "", the message is "unknown event: ", which ends with the space after the colon.
  • An empty events list returns [].

Try it

Once your code passes, delete the case "deleted" branch and run mypy again. You get the error above. Put the branch back before you submit.

The diagram follows event through three comparisons. Choose else at each step, and no event is left. That empty type is the Never that assert_never expects. Choose then to follow one event into its branch.

Loading visualization…

Going further