skip to content

Exhaustiveness Checking

Getting a compile error when a new variant is not handled anywhere, and how one lazy catch-all case throws that guarantee away. Interviewers use it to test whether you design for change or for today.

on this pageshow

questions

4

What does a compiler's exhaustiveness check on a match over ticket states actually guarantee?

level: juniorimportance: must knowfreq 62%

answer

  1. checked before the program runs
  2. every variant, not every value
  3. compiler enumerates, then subtracts patterns
  4. a missing case is a build error
  5. pays out when a new variant appears

basics

~20 s

An exhaustiveness check proves at compile time that a match handles every variant its type can hold. The payoff comes later: add a variant and every match with a gap fails the build instead of falling through at run time.

solid answer

~50 s

An exhaustiveness check is a compile-time proof that a match over a closed set of variants leaves no variant without a case. If a ticket's state is one of `New`, `Open`, `Waiting` or `Resolved` and a match handles only the first three, the code does not compile and the compiler names the variant that is missing. Written that way it sounds like a typo-catcher, and on the day you write the match it mostly is. Its value shows up a year later: when `Escalated` is added to the type, every match anywhere in the program that has no case for it becomes a build error, so the list of places that need a decision is produced by the compiler rather than by a search across the repository or by a support incident. Two conditions make that possible — the variant set must be closed, and no case may match everything.

code

pseudocode · 9 lines
pseudocode
type TicketState = New | Open | Waiting | Resolved

function label(state)
  match state
    case New      -> "just filed"
    case Open     -> "being worked"
    case Resolved -> "closed out"

// rejected at compile time: the variant Waiting is covered by no pattern

go deeper

for a junior

Be able to say what is checked and when: every variant of the matched type has a case, decided at build time, not while the program runs. The compiler names the variant you left out.

for a middle

Explain the mechanic — enumerate the type's variants, subtract the covered patterns, report the remainder — and why the payoff lands when a variant is added rather than when the match is first written.

for a senior

Show that you treat the resulting error list as the migration work list, and that you know which sites it cannot list: the ones that already end in a case matching everything.

for a principal

Frame it as a choice about where change is detected. A closed, checked type moves the cost of a new variant to build time for every consumer, which is cheap inside one codebase and a release-coordination problem across many.

## What the check actually is **Exhaustiveness checking** is a static analysis a compiler runs over a **match**: a construct that inspects one value and selects a branch according to which variant that value is. The analysis has three moving parts — the **static type** of the value being matched, the **variant set** that type declares, and the **patterns** the match lists. The compiler enumerates the variant set, removes every variant that some listed pattern covers, and looks at what is left. An empty remainder means the match is **total** and the code compiles. A non-empty remainder means some value of that type would run off the end of the match with nowhere to go, so the compiler rejects the code and names what is uncovered. Run it against a support ticket whose state is one of `New`, `Open`, `Waiting` or `Resolved`: 1. The declared type admits exactly those four variants. 2. The match lists cases for `New`, `Open` and `Resolved`. 3. Subtracting leaves `Waiting`, so the build fails and points at that variant. Nothing executes during any of this. No ticket has to exist and no branch has to run; the proof is over declarations alone, which is precisely what makes it a guarantee rather than an observation about the inputs you happened to try. ## The guarantee is mostly about the future On the day the match is written the check is a typo-catcher — you know the four states, you wrote four cases, and being told you forgot one is mildly useful. The real product arrives when the **type changes**. Add `Escalated` a year later and the compiler re-runs the same subtraction at every match over that type in the program. Each match with no case for the new variant becomes a build error. That turns a data-shape change into something unusual: a change whose blast radius is **computed**, not estimated. The set of places that must decide what an escalated ticket means is handed to you by the build, complete for the code being compiled, before anything is deployed and before any ticket is in that state. Compare with the ways a codebase dispatches on a state when it does not have this property: | How the state is dispatched on | What the build says when a fifth state appears | What happens at run time | |---|---|---| | Match listing every variant, no wildcard | Error at every site missing a case | Nothing reaches production unfixed | | Match ending in a wildcard | Silence — the match is still total | The new state quietly takes the wildcard's branch | | A chain of equality tests on a tag field | Silence — the chain is only conditionals | The final fallback absorbs the new state | | A lookup table keyed by state | Silence — the table is data, not code | A missing key fails or yields a default, wherever it is read | Only the first row converts the change into work the compiler hands you. Everything else converts it into work that a user, a log line or an incident eventually hands you. ## What the check does not prove It is worth being exact about the scope, because it is routinely claimed to deliver more than it does. - It proves **coverage, not correctness**. A match that handles all four states and returns the wrong label for two of them passes the check happily. - It is not the **reachability** analysis. Whether an earlier pattern makes a later case dead is a separate question with a separate diagnostic; a match can be exhaustive and still contain a case that never runs. - It does not remove the need for a **failure path at the boundary**. A state arriving as stored text or over a network is not yet a variant of the closed type, and the code that decodes it needs its own answer for input it does not recognise. - It does not protect **other programs**. The proof covers what this compiler compiles; a separate component built before the variant existed is outside it entirely. - It does not survive a **wildcard**. One case that matches everything makes the match total forever, and from then on the compiler has nothing to report at that site. ## Why interviewers ask it The question separates two habits of mind. One candidate experiences the check as a compiler nuisance to be satisfied, and satisfies it permanently with a case that matches anything. The other sees it as the mechanism that makes a change in the data's shape **visible everywhere it matters**, and guards it. That is why the follow-up is almost always about the catch-all: the guarantee is easy to state and trivially easy to throw away. One framing carries the whole idea: the check moves the discovery of an unhandled case from *run time, and only on the paths that execute*, to *build time, on all paths*. Every other benefit here is a consequence of that single shift.

  • Does an exhaustiveness check say anything about whether each case body is correct?
    No. It proves coverage, not correctness. A match that handles all four ticket states and returns the same wrong label for two of them passes. Coverage is a structural property a compiler can decide from declarations; whether a waiting ticket should notify the reporter is a behavioural claim that only a test or a reviewer settles.
  • A ticket's state arrives as text from stored data — is that covered by the check?
    Not until it is decoded. While it is text it is not a variant of the closed type, so the decoder is where unrecognised input must produce a deliberate failure. The guarantee begins downstream of that decode: every match on the decoded value is covered, and the decode step itself needs an error path of its own.
  • Why is a build error a better signal here than a log line at run time?
    Because it is complete and it is early. A build error appears at every site that needs a decision, including the ones no test exercises and no user has hit yet, and it appears before deployment. A log line appears only for paths that actually ran, in an environment where a ticket is already being handled wrongly.

saying these in an interview costs you the question

  • Says the check proves each branch does the right thing
  • Thinks the missing case is reported at run time
  • Believes a case matching everything keeps the guarantee intact
  • Assumes it also validates values arriving from outside the program
  • Confuses it with checking that every listed case is reachable
open as a page

A match over ticket states ends with a catch-all case — what does that cost when a new state is added?

level: middleimportance: must knowfreq 66%

basics

~20 s

A catch-all makes the match total for every variant, including ones that do not exist yet, so the compiler reports nothing when a state is added. The new state silently takes a branch written for the variants of a year ago.

open as a page

What must be true of the ticket-state type before a compiler can prove a match over it is exhaustive?

level: middleimportance: should knowfreq 44%

basics

~20 s

The variant set must be closed and fixed at compile time: declared in one place, with no way for other code to add a variant later. Only a finite, known set gives the compiler something to subtract the listed patterns from.

open as a page

Adding a ticket state breaks the build at every match site — how do you sequence that change across a large codebase?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Add the variant first with nothing producing it, then work the compiler's error list site by site. That list is complete only for matches that never had a catch-all, so audit existing catch-alls and every decoder of stored values separately.

open as a page