The Case You Forgot to Handle
How adding an enum case can quietly break everything, and how to fix it.
You add a new member to an enum. You wire it up in the few places that need it. You ship it and move along. A couple weeks later something downstream starts doing the wrong thing. No crash, no traceback, just the wrong result coming from a case nobody handled.
Here's how I make it impossible.
The problem
Say we read imagery from a few satellite platforms. There's a fixed, known set of them, so you reach for an enum, exactly what it's for:
from enum import StrEnum
class SatellitePlatform(StrEnum):
SENTINEL2 = "sentinel2"
LANDSAT8 = "landsat8"
TERRA = "terra"
The usual way to dispatch on one of these is a chain of if/elif, or a
match statement:
def load_scene(platform: SatellitePlatform, path: str) -> Scene:
if platform is SatellitePlatform.SENTINEL2:
return read_sentinel2(path)
elif platform is SatellitePlatform.LANDSAT8:
return read_landsat8(path)
elif platform is SatellitePlatform.TERRA:
return read_terra(path)
else:
raise ValueError(f"unhandled platform: {platform}")Both work. But they grow top-to-bottom, they mix selection with behaviour,
and that trailing else (or case _:) only complains at runtime, if that
branch is ever reached. Add SatellitePlatform.SENTINEL1 next quarter and
forget this function? Nothing stops you.
The dispatch table
Pull the mapping out into a dictionary. Keys are the cases, values are the handlers:
from collections.abc import Callable
READERS: dict[SatellitePlatform, Callable[[str], Scene]] = {
SatellitePlatform.SENTINEL2: read_sentinel2,
SatellitePlatform.LANDSAT8: read_landsat8,
SatellitePlatform.TERRA: read_terra,
}
def load_scene(platform: SatellitePlatform, path: str) -> Scene:
return READERS[platform](path)
This is a dispatch table. The selection logic is now data, not control
flow. It's flatter, it's trivial to extend, and load_scene shrank to a single
line.
But we haven't fixed the real problem yet. Add a new platform and forget to add
its reader, and READERS[platform] raises a KeyError at runtime, in
production, when the first scene tries to load. We just moved the surprise; we
didn't remove it.
Fail at import, not in production
Here's the check that changes everything:
missing = set(SatellitePlatform) - set(READERS)
assert not missing, f"every SatellitePlatform needs a reader, missing: {missing}"
Put that right under the dictionary, at module level. Now the check runs the instant
the module is imported, before a single scene is loaded, before your tests
even start. Add SatellitePlatform.SENTINEL1 without a reader, and the import
blows up immediately with a clear message.
What this actually is
Verifying that every case of a closed set is handled has a name:
exhaustiveness checking. The assert is just the simplest way to do it in
Python; the point is that the check lives next to the data and runs as early
as possible.
That's the whole trick: the assert that catches Future You. The failure moves from "a scene silently failed to load, let's dig through logs" to "the app won't even start, and the error tells me exactly what I forgot."
When to reach for it
This pays off when:
- you're dispatching on an enum or other closed set, and
- every case must be handled (no sensible default), and
- the set is likely to grow over time.
If a plain default genuinely makes sense ("anything else, log and skip"), keep
the else and skip the assert. And if you only ever dispatch in one place, the
if/elif is fine; don't over-engineer it. The dispatch table earns its keep
once the same mapping shows up in more than one function, or the list of cases
gets long.
Going further: catch it one step earlier, at type-check time
If you keep the branch-by-branch form (if/elif or match), a type checker
can catch the missing case even before import. Put assert_never(platform) on
the fall-through branch and the checker verifies exhaustiveness for you: add
SatellitePlatform.SENTINEL1 and mypy (or pyright) flags it immediately, since
the fall-through is now reachable and platform isn't Never. No import, no
runtime, no tests, just a red squiggle. The catch: this only works on the
branch-by-branch form. Switch to the dispatch table and you trade static
narrowing for the runtime assert, so it's a real choice, not a strict upgrade.
The takeaway
None of this is clever; it's a dict and an assert, both of which you already
know. That's exactly why I like it: no framework, no metaclass, nothing to
look up later. Just a habit that turns a silent, someday bug into a loud,
right-now one.
Make illegal states un-importable, and let the computer remember the cases so you don't have to.