skip to content

Your TypeScript library exports a discriminated union of event types, and consumers switch over it exhaustively so the leftover value in `default` is `never`. Adding one member breaks every consumer's build. How do you weigh keeping that compile-time exhaustiveness against letting consumers absorb unknown members?

level: principalimportance: nice to knowfreq 27%

answer

  1. exhaustiveness couples you to your consumers
  2. who owns the build changes the answer
  3. finite set versus growing set
  4. additive should not mean major
  5. strict inside, stable at the edge

basics

~20 s

Decide whether the union is closed or open, and say so in the contract. A closed union makes every addition a breaking change consumers must handle; an open union asks consumers to keep a real fallback branch and gives up compile-time exhaustiveness.

solid answer

~50 s

Exhaustiveness is a coupling contract, not just a coding trick: the moment a consumer relies on the `never` tail, every member you add is a breaking change for them. Inside one repository that is exactly what you want — the build enumerates every site that must be revisited, and you fix them in the same change. Across a published package with independently upgrading consumers it is a liability, because a feature that should be additive forces a major version and a coordinated upgrade. So the real decision is whether the union is *closed* (finite, owned by you, additions are semver-major) or *open* (expected to grow, consumers must tolerate unknown members). Declare which one it is in the type's documentation, keep the exhaustive form for internal unions, and for the public surface give consumers a documented fallback obligation rather than a silent break.

go deeper

for a junior

Know that adding a member to a union can break code elsewhere that switched over it exhaustively, so extending a union is not automatically a safe change.

for a middle

Explain why the break happens — the leftover value stops being never — and why that is desirable inside one repository, where the compiler lists every site to update.

for a senior

Argue the boundary case: across a published package, an additive change forced into a major version is a real cost, and consumers under pressure will paper over it in worse ways.

for a principal

Own the policy. Decide and document whether each exported union is closed or open, keep strict unions internal while exporting stable ones, and treat a change to that policy as itself a breaking release.

## The exhaustiveness check is a contract, not a style choice When a consumer writes a `default` branch that assigns the leftover value into a `never`-typed position, they are asserting: *this union will not grow without me noticing.* That assertion is enormously useful and it is also a coupling. Every member you add invalidates it. Whether that is a feature or a defect depends entirely on who owns the compile step. ## Inside one build, breakage is the point For a union that lives in the same repository as its consumers, forcing the break is close to strictly good. Adding a member and fixing the resulting errors is one change, reviewed together, and the compiler produces the exact list of places that must be updated. The alternative — a permissive default everywhere — means the new member silently takes the fallback path and the gap surfaces as behaviour rather than as a build failure. Here you should encourage exhaustive switches, and you may reasonably enforce them in review. ## Across a package boundary, breakage is a cost you are imposing Once the union is exported and consumers upgrade on their own schedule, the calculus inverts. A new event type is conceptually additive — old consumers do not need to care about it — but the exhaustive switch turns it into a compile error, so the release is semver-major. You now pay for every additive change with a major version, a migration note and a long tail of pinned consumers. Worse, consumers under upgrade pressure will "fix" it by widening their default branch or reaching for an escape hatch, which is a worse outcome than if you had asked for a fallback in the first place. ## The decision: is the union closed or open? **Closed union.** The set of members is finite, semantically complete and owned by you: HTTP method, payment status in a fixed state machine, the arms of a result type. Additions are genuinely rare and genuinely require consumer attention. Publish it as closed, document that additions are major, and let consumers rely on exhaustiveness. **Open union.** The set is expected to grow: event types on a bus, telemetry kinds, plugin messages, anything mirroring a protocol you do not fully control. Publish it as open, document that consumers must handle unrecognised members, and expect the additions to be minor. The failure mode in real codebases is not choosing wrong; it is never deciding, so the type is closed by accident and every addition triggers an argument. ## Making the choice legible A few things make the intent survive contact with consumers: - **Say it in the doc comment on the exported type.** "This union is open — handle unknown members" is the cheapest and most effective control you have. - **Give the open union a real fallback member** rather than expecting consumers to invent one. An explicit member such as `{ kind: 'unknown'; raw: unknown }` that your own parsing produces for unrecognised input turns the fallback into a typed, handled path instead of an unmodelled hole — and keeps consumers' switches exhaustive over a union that no longer needs to grow for every new upstream kind. - **Keep the internal union closed.** Nothing stops you from switching exhaustively over a rich internal union and mapping it onto a narrower, more stable public one. That is the usual way to get both properties: strict checking where you own the build, stability where you do not. - **Version the type with the runtime.** If consumers can install a package version whose types describe more members than the runtime emits, or fewer, the exhaustiveness story is noise anyway. ## Second-order effects worth naming - **Ecosystem size.** Ten internal consumers you can fix in an afternoon is a different problem from an open-source package with unknown adopters. - **Cost of missing a case.** If an unhandled event silently drops a payment, the forced break is cheap insurance. If it merely skips a log line, the major version is poor value. - **Upgrade coupling.** Forced breaks encourage consumers to pin old versions, which is precisely the population you least want stuck on stale code when a security fix ships. - **Deprecation of members.** Removing a member breaks consumers even when the union is open, so the open/closed decision only governs additions. ## What a strong answer sounds like It does not defend one position universally. It separates the same-repository case (break loudly, the compiler is your migration list) from the published-package case (additive should stay minor), names the closed-versus-open decision as the thing that must be written down, and offers the internal-strict/external-stable split as the way to keep both. It also acknowledges the pre-existing consumers: whichever policy you adopt, the first release that changes it is itself a breaking change and needs to be communicated as one.

  • If you decide the public union is open, what do you owe consumers so their code is not just fragile in a different way?
    A written statement on the exported type that it is open, a documented fallback obligation, and ideally an explicit member representing an unrecognised value so the fallback is a typed path rather than an unmodelled hole. Without those, "open" just means consumers guess.
  • How does the internal-strict, external-stable split actually work in practice?
    Keep the rich union private and switch over it exhaustively where you own the build, then map it to a narrower exported type at the boundary. Adding an internal member forces you to decide how it surfaces publicly, and consumers only see the change if it genuinely affects them.
  • Does removing a member from the union behave the same way as adding one?
    No. Removal breaks consumers regardless of whether the union is open or closed, since their existing case clauses reference a tag the type no longer has. The open/closed policy governs additions only; removals are always a major change and need a deprecation window.
  • How would you migrate a union that is closed by accident to an openly documented policy?
    Treat the policy change itself as breaking. Ship a release that documents the union as open, introduces the fallback member so consumers have somewhere to put unknown values, and gives a window before the first additive minor lands. Changing the rules quietly is worse than the original coupling.

saying these in an interview costs you the question

  • Says exhaustive switches are always correct regardless of consumers
  • Treats adding a union member as never breaking
  • Assumes a permissive default is the same as a documented policy
  • Ignores that consumers upgrade on their own schedule
  • Says removals and additions carry the same compatibility risk

context