How does typing.assert_never turn a missed Enum member into a type error?
answer
- Make the compiler notice the new variant
- The unreachable branch has a type
- Bottom type: nothing inhabits it
- Only accepts what cannot exist
- Standard library since 3.11
basics
~20 sOnce every member is handled, a checker narrows the fall-through branch to Never. typing.assert_never accepts only a Never argument, so adding a new member makes that call fail to type-check — the miss becomes a build error instead of a silent default.
solid answer
~50 sExhaustiveness checking rides on narrowing. If you dispatch over an `enum.Enum` with `match`/`case` or an `if`/`elif` chain and handle every member, the wildcard branch is unreachable, so the checker narrows the value there to `Never`, the type with no values. `typing.assert_never`, added in 3.11, is annotated to take a `Never`; passing anything else is an error. Add a fourth member to the enum and the wildcard branch is suddenly reachable with that member, the argument no longer matches `Never`, and the checker points straight at the dispatch you forgot to update. At runtime the call raises `AssertionError` if it is ever reached, which is the right behaviour for values that entered from outside the type system. The same trick works over a `Literal` union of strings, and it is the cheapest way to make a variant set safe to extend.
code
python · 23 linesimport enum
from typing import assert_never
class Severity(enum.Enum):
INFO = "info"
WARN = "warn"
ERROR = "error"
def sink(level: Severity) -> str:
match level:
case Severity.INFO:
return "stdout"
case Severity.WARN:
return "stderr"
case Severity.ERROR:
return "pager"
case _:
assert_never(level)
print([sink(level) for level in Severity])go deeper
Recognise the pattern when you see it: a final catch-all branch calling typing.assert_never means the code is claiming every variant above was handled. Do not delete it to silence an error.
Explain the mechanism: narrowing subtracts each handled member until the fall-through branch has type Never, and assert_never accepts only that type, so a new member breaks the call.
Demonstrate where to place it in a real system — every dispatch on a variant set that will grow — and explain why the runtime AssertionError still matters for values crossing in from outside the type system.
Own the extension policy: whether variant sets are closed and checked this way or open with a documented default, who is expected to fix the resulting errors when a member lands, and how that interacts with rolling out producers and consumers at different times.
**The problem it solves.** Dispatch over a closed set of variants — an `enum.Enum` of severities, a `Literal` union of codec names, a set of message classes — is everywhere. The failure mode is not the code you write today; it is the member somebody adds in six months, whose new value silently falls into a `else: pass` or a `case _:` default in three of the eleven places that dispatch on it. Exhaustiveness checking converts that from a production surprise into a type-check failure at the moment the member is added. **How it works: narrowing to the bottom type.** `typing.Never` is the bottom type: no value has it. Narrowing produces it naturally. If a value declared as an enum is compared against each member in turn, then in the final `else` the checker has subtracted every member and is left with nothing — `Never`. The same happens in a `match` statement whose `case` patterns cover every member and whose final `case _:` is therefore unreachable. `typing.assert_never(value)` is declared to take an argument of type `Never`, so the call type-checks *only* when the checker agrees the branch is unreachable. Add a member, the branch becomes reachable with that member's type, the argument no longer matches, and you get an error naming the offending type — which is precisely the list of dispatches you must update. ```python import enum from typing import assert_never class Severity(enum.Enum): INFO = "info" WARN = "warn" ERROR = "error" def sink(level: Severity) -> str: match level: case Severity.INFO: return "stdout" case Severity.WARN: return "stderr" case Severity.ERROR: return "pager" case _: assert_never(level) ``` **Runtime behaviour.** `assert_never` is a real function, not a compile-time marker, and reaching it raises `AssertionError` including a repr of the argument. That is not redundant with the static check: values cross into a typed program from JSON, environment variables and databases, and a checker only ever verified the *declared* type. If an ingest stage decodes a severity string it has not seen — the kind of drift that shows up alongside an encoding mismatch, when an upstream producer changes format — the dispatch fails loudly at the boundary instead of quietly routing the record nowhere. Unlike an `assert` statement, this guard is a function call and survives running the interpreter with `-O`. **Where it applies.** Enum members, `Literal` unions of strings or ints, and unions of classes narrowed by `isinstance` all work, as long as the checker can see the declared type precisely. It cannot help if the value has been widened to `str` or `object` on the way in, if the enum gains members dynamically, or if you compare with `==` against something the checker cannot tie back to the literal set. In those cases the fix is upstream: parse once into the enum, then dispatch on the enum. **The weaker alternatives.** A declared non-optional return type gives partial exhaustiveness for free: if a function annotated `-> str` has an unhandled path that falls off the end returning `None`, that is an error. But it only works for functions that return a value, the error message points at the function rather than the missed variant, and it does nothing for statement dispatch that performs side effects. `raise AssertionError(f"unhandled {level!r}")` gives the same runtime safety and zero static safety. `assert_never` gives both, in one line. **Practical use.** Put it in the default branch of every dispatch that must be revisited when the variant set grows, and treat any checker error naming it as a to-do list rather than a nuisance. On 3.10 and earlier the helper does not exist in the standard library; a one-line local shim taking a `Never` parameter and raising behaves identically, and checkers recognise the pattern. **Why a dispatch table is not equivalent.** Replacing the branches with a mapping — `SINKS: dict[Severity, str]` and a lookup — reads well, but it gives up the check entirely: a dict literal missing a member is a perfectly well-typed dict, and the miss surfaces at runtime as a `KeyError` on whichever record happens to carry the new severity. If you prefer the table form, get exhaustiveness back by building it from the enum and asserting the sizes match at import time, or keep the `match` form for the dispatches that matter most. The general rule is that exhaustiveness checking is a property of *branching* code, so it costs a branch to have it. **Cost and placement.** The pattern is close to free: one extra branch and one call that never executes in a correct program. What it is not is a substitute for validation at the edge. In an ingest pipeline the right shape is a single parsing step that turns an incoming string into an enum member, raising on anything unknown, followed by internal dispatches that all end in this guard. Then the static check protects developers extending the variant set, and the parser protects the process from data that never respected the type in the first place.
- What happens at runtime if the assert_never branch is actually reached?It raises `AssertionError` with a repr of the value. That matters because static checking only ever validated the declared type: a severity string decoded from an upstream payload can be a member nobody declared. Failing loudly at the dispatch point beats routing the record nowhere. Being a function call rather than an `assert` statement, it is not removed under `-O`.
- How is a declared return type a partial substitute for it?If every branch of a function annotated `-> str` returns and you miss one variant, the implicit `return None` path is a type error, so you get exhaustiveness for free. But it only works for value-returning functions, it says nothing about which variant was missed, and it does nothing for dispatch that performs side effects instead of returning.
- Why might a checker refuse to narrow to `Never` even though every member is handled?Because narrowing depends on the declared type being precise. If the value arrives typed `str` or `object`, if it was widened by a helper, or if members are added to the enum at runtime, the checker cannot prove the branch unreachable. The fix is to parse the raw value into the enum or literal type once, at the boundary, and dispatch on that.
It is a tripwire at the end of a corridor of doors: while every door is accounted for nobody can reach it, and the day someone cuts a new door the tripwire is suddenly reachable — which is exactly when you want to hear about it.
saying these in an interview costs you the question
- Thinks assert_never checks exhaustiveness at runtime
- Uses a bare `else: pass` and calls the dispatch exhaustive
- Says match/case is exhaustive without a wildcard case
- Claims assert_never has always been in typing
- Confuses Never with None as the parameter type
- Assumes new enum members are caught automatically at runtime