skip to content

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%

answer

  1. the compiler goes quiet
  2. one case that matches everything
  3. a new variant takes an old branch
  4. build error traded for run-time behaviour
  5. lost per site, not per type

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.

solid answer

~50 s

The catch-all covers whatever the listed patterns did not, so the subtraction the compiler performs is empty by construction and stays empty forever. When `Escalated` joins the ticket type, that match keeps compiling and the new state quietly runs a branch chosen for a world that did not contain it — routing an escalated ticket to the assignee rather than to the on-call manager, say. You have exchanged a build error, delivered to a developer before deployment, for behaviour that is plausible enough to survive review and gets discovered by a customer. The loss is also invisible in aggregate: the type is still closed and every other match keeps its guarantee, so the guarantee erodes one site at a time. The honest exception is a catch-all whose branch states a genuine policy over all other variants — there, a future state joining it is the intended result, not an accident.

code

pseudocode · 10 lines
pseudocode
function notifyTarget(state)
  match state
    case New     -> "triage queue"
    case Open    -> "assignee"
    case Waiting -> "reporter"
    case _       -> "assignee"    // written when Resolved was the only leftover

// a year later the type gains Escalated.
// this still compiles. escalated tickets now notify the assignee
// instead of the on-call manager, with no error and no warning.

go deeper

for a junior

Know that a case matching anything makes a match compile whatever variants exist, and that this is why the compiler stays silent when a new state is added later.

for a middle

Explain the exchange precisely: a build error you would have received is traded for a run-time branch that was chosen for variants which did not exist when it was written.

for a senior

Show judgment about which catch-alls encode a policy and which are omissions, and treat removing the omissions as part of preparing a codebase for a data-shape change.

for a principal

Weigh the guarantee against churn. Listing variants everywhere makes each addition a codebase-wide edit; the usual answer is to keep the check and limit how far the closed type travels.

## What the catch-all does to the analysis A **catch-all** (a case whose pattern matches any value the earlier cases did not) is not just a convenient shorthand. It changes the compiler's answer from *computed* to *trivially true*. The subtraction that exhaustiveness rests on — declared variants minus covered variants — can no longer have a non-empty remainder, because one pattern covers everything left over. The match is total today, and it is total against every variant that will ever be added to the type. From the compiler's point of view there is nothing further to say about this site, now or in any future build. That is the whole cost, stated once: **the catch-all removes this match from the list the compiler produces when the type changes.** ## The cost is deferred, not avoided The decision about how an escalated ticket should be routed has to be made by somebody. Exhaustiveness checking chooses *when* and *who*: at build time, by the developer changing the type, with the compiler naming each site. A catch-all makes a different choice: at run time, by whoever wrote a fallback branch a year earlier for an entirely different reason, on behalf of a variant they never imagined. What makes this worse than an ordinary bug is that the fallback is usually **plausible**. It returns a value of the right type, it does not throw, it passes review, and the behaviour it produces looks like a reasonable answer until someone notices escalated tickets are never reaching the on-call rota. | The match's last case | Build when a variant is added | When the problem surfaces | What a user experiences | |---|---|---|---| | Every variant listed, no catch-all | Fails, naming each site | Before deployment, on all paths | Nothing — it was fixed first | | Catch-all returning a plausible value | Passes silently | Whenever someone notices the wrong outcome | Quietly wrong behaviour | | Catch-all raising an error | Passes silently | On the first execution of that path | A failure, loud but live | The third row is worth reading carefully: raising in the catch-all converts a silent wrong answer into a loud failure, which is a real improvement, but the build stayed green, so the change still shipped, and the failure still waits for a path to execute. ## Why "it still compiles" is the problem Candidates often defend the catch-all as robustness — the match can never fail to produce a value. That framing is exactly inverted for a **closed** type. Over a closed set there is no unknown variant to be robust against inside the program; every value was constructed from the declared list. The only thing the catch-all protects against is *the future*, and protecting the code from a build error is not the same as protecting the behaviour from being wrong. There is a boundary where the defence is real: a value being decoded from stored data or from another component is not yet a variant of the closed type, so the decoder must have an answer for input it does not recognise. That answer belongs in the decoder, as an explicit failure, not sprinkled through every match downstream of it. ## Catch-alls that are decisions, not omissions A blanket rule against catch-alls would be wrong, and interviewers will test that too. A catch-all earns its place when its branch states a policy over **all other variants** that a future variant should follow by default: - "anything that is not resolved counts as open work" for a count on a dashboard, - "anything that is not a terminal state keeps the response-time clock running", - a display fallback where an unknown state is deliberately shown as ordinary. The test is one question: *would you be content for a state nobody has invented yet to take this branch?* If yes, the catch-all is a decision and should carry a comment saying so. If the honest answer is "it depends what the state is", list the variants and let the compiler ask again later. ## Repairing a codebase that is full of them 1. **Find them.** They do not fail, so nothing surfaces them; a search for all-matching cases over the type is the only pass that finds them. 2. **Classify each.** Policy, or omission? Policy stays and is documented. Omission is replaced by the remaining variants listed explicitly. 3. **Do it before the variant is added**, not during. Cleaning up under the pressure of a half-finished feature is how the catch-alls got there. ## The name for the trade-off This sits inside a known asymmetry called the **expression problem**: with a closed type matched from many places, adding an operation is cheap — one new function, touching nothing — while adding a variant touches every match. Exhaustiveness checking does not create that asymmetry. It makes the expensive side **visible at build time** rather than leaving it to be found later, and a catch-all is a way of pretending the cost is not there.

  • Is a catch-all that raises an error instead of returning a value an acceptable substitute?
    It is better than a plausible wrong answer, because the surprise becomes loud. It is not the guarantee, though: the build stays green, so the new variant ships, and the failure appears only on paths that actually execute — possibly in production, possibly at night. Where the value genuinely comes from outside the closed set, that failure path is required rather than optional.
  • When is a catch-all the right thing to write in a match over a closed set?
    When its branch states a policy over every other variant that a future one should join by default — "anything not resolved counts as open work" is a decision, not an omission. The test is whether you would be content for a state nobody has invented yet to take that branch. If the honest answer is "it depends", list the variants instead.
  • What is the name for the trade-off where adding a variant is costly and adding an operation is cheap?
    The expression problem. With a closed type matched from many places, a new operation is one new function that touches nothing, while a new variant touches every match. Exhaustiveness checking does not cause that asymmetry; it makes the expensive side visible at build time instead of leaving it to be discovered in production.

A catch-all case is like a mail room rule that sends anything unrecognised to the same desk. Nothing ever jams, which is exactly why nobody notices when a whole new kind of letter starts arriving there.

saying these in an interview costs you the question

  • Says a catch-all is always the safe, defensive default
  • Thinks the compiler still warns once a new variant joins it
  • Believes raising inside the catch-all restores the compile-time guarantee
  • Assumes the cost stays local to the file containing it
  • Treats listing the remaining variants as pointless duplication
  • Claims a catch-all never belongs in a match over a closed set