A library publishes a closed set of subtypes in v1, downstream code matches exhaustively over it, and v2 adds one more member. What actually breaks, and how do languages differ in what they force the library author or the client to write?
answer
- exhaustive match = a proof about a set; enlarging the set kills it
- source-breaking (loud) vs binary-stale (silent then fatal)
- Rust #[non_exhaustive]; Swift non-frozen + @unknown default
- Java/Kotlin: no marker, run-time MatchException / NoWhenBranchMatched
- seal specs and protocols, not business categories
basics
~20 sAdding a case breaks every exhaustive match. Recompiled clients get a compile error; un-recompiled ones fail at run time -- Java throws MatchException, Kotlin NoWhenBranchMatchedException. Rust's #[non_exhaustive] and Swift's non-frozen enums pre-charge clients a catch-all arm to make the addition non-breaking.
solid answer
~50 sSealing a public hierarchy is a promise that the set is complete; adding a case revokes it. - **Java**: no opt-out marker exists. A recompiled client gets a helpful compile error; a client still running against the v1 classes hits `MatchException` from the generated switch, so the change is source-breaking *and* binary-hazardous. The author's only levers are recompiling everything or not sealing. - **Kotlin**: identical shape, with `NoWhenBranchMatchedException` at run time -- the reason a `when` used as a statement rather than an expression is the more dangerous form. - **Rust**: `#[non_exhaustive]` on an enum forces downstream crates to write a wildcard arm from day one, so adding a variant is a non-breaking release. You pay an unreachable arm forever to buy that. - **Swift**: the same bargain, defaulted the other way for resilient libraries -- enums are non-frozen unless marked `@frozen`, and `@unknown default` still warns so clients can find the sites. Decide at publication which promise you can keep.
code
rust · 11 lines// library v1
#[non_exhaustive]
pub enum Channel { Email, Sms }
// downstream crate must write this today:
match ch {
Channel::Email => send_email(),
Channel::Sms => send_sms(),
_ => log_unsupported(), // required by #[non_exhaustive]
}
// library v2 adds Channel::Push -> still a semver-compatible releasego deeper
Know that adding a case breaks exhaustive matches and that the compiler will point at them after recompilation.
Separate source-breaking from binary-stale failure, and name the run-time exception one language throws for a stale client.
Compare an explicit evolution marker (Rust, Swift) with a language that has none, and give a concrete rollout policy including the no-silent-wildcard rule.
Treat sealing as a published promise: decide per type whether the domain is genuinely finite, define the release unit that makes recompilation guaranteed, and set the versioning policy for the exceptions.
## The change and its blast radius An exhaustive match is a compile-time proof about a set. Enlarging the set invalidates every such proof in the program -- including proofs compiled into artefacts you do not control. Two distinct compatibility questions follow, and candidates who conflate them give weak answers. **Source compatibility**: will client code still compile? For a sealed hierarchy with exhaustive matches, no. Every match that relied on totality now fails to compile. This is loud, guided and usually welcome -- the compiler hands you the work list. **Binary compatibility**: will an already-compiled client still run? Also no, but silently until it hits the new case. This is the dangerous half, because it only fails on the input that carries the new case, possibly in production, possibly months later. ## How each language handles it **Java (sealed classes, standard since 17).** The `permits` clause is a compile-time fact. Pattern-matching switches over a sealed type compile to a form that throws `MatchException` when nothing matches, so a stale client gets a run-time failure rather than a silently wrong branch -- better than a silent default, still a production incident. Java offers no `non_exhaustive` equivalent. Its levers are: do not seal a type whose case set may grow; keep the sealed type inside a module recompiled as a unit; or make the hierarchy `non-sealed` at the branch that must grow, which surrenders exhaustiveness there. **Kotlin.** Sealed classes and interfaces behave the same. A `when` used as an expression must be exhaustive, so the compiler catches the gap on recompilation; a stale client throws `NoWhenBranchMatchedException`. Since Kotlin 1.7 a `when` statement over a sealed subject also warns/errors on non-exhaustiveness, closing the older hole where a statement `when` silently did nothing. **Rust.** Rust anticipated the problem at the library level with `#[non_exhaustive]`. Applied to an enum, matches in *other* crates must include a wildcard arm; matches inside the defining crate stay exhaustive. The result is that adding a variant is a semver-compatible change. Applied to a struct, it prevents downstream literal construction so fields can be added later. The cost is real and permanent: every downstream match carries an arm that is unreachable today, and if the wildcard is written as a silent no-op, new cases are ignored rather than handled. **Swift.** Swift makes the same bargain but defaults the other way for libraries built with library evolution: a public enum is **non-frozen** unless annotated `@frozen`, so clients must write `@unknown default`. Crucially, `@unknown default` is not a plain wildcard -- the compiler still warns when the client is rebuilt against a version that has new cases, so the sites can be found and handled. That is the most refined design of the four: safe by default, with a rebuild-time nudge. **C#.** C# has no sealed discriminated unions as of C# 13, so teams emulate them with an abstract class and a private constructor. Switch expressions over such an emulation throw `SwitchExpressionException` when nothing matches -- the same run-time backstop, without any language support for evolving the set. ## The design decision The question is not "sealed or open" but **what promise you can keep for the lifetime of the API**. - Seal when the case set is fixed by something outside your control: a frozen wire format, opcode set, a mathematical shape such as success/failure, an AST for a language whose grammar is stable within a major version. - Do not seal, or use `#[non_exhaustive]`-style markers, when the set encodes business categories -- payment methods, notification channels, tenant tiers -- because those grow whenever the business does. - If you must add a case to a sealed public type, treat it as a major-version change and say so in the release notes. Recompilation is part of the upgrade, not an optional extra. ## Operational advice Three practical habits distinguish a senior answer: 1. **Keep the sealed type and its consumers in one release unit** where possible, so "source-breaking" is the whole story and binary staleness never arises. 2. **Never write a silent catch-all.** If the language forces a wildcard arm (Rust, Swift), make it fail loudly or log and degrade explicitly -- a wildcard that returns a default value converts a compile-time proof into a silent behaviour change. 3. **Separate the internal and published views.** Some codebases seal an internal representation for exhaustiveness and expose a deliberately open interface at the module boundary, so internal code gets the compiler's help while consumers get room to move. ## Interview shape Split source from binary compatibility, name one language with an explicit evolution marker and one without, and land on the framing that sealing is a versioning commitment made at publication time.
- Rust and Swift both force a catch-all arm on clients. Why is Swift's `@unknown default` considered stronger than a plain `_` wildcard?A plain wildcard is invisible once written -- it absorbs every future case with no signal. `@unknown default` is a distinct arm that the compiler warns about whenever the client is rebuilt against a version containing cases not explicitly handled, so the upgrade produces a work list instead of silence. It gives the client the same guided-refactor benefit that exhaustiveness gives inside the defining module, without breaking the build.
- If you cannot avoid adding a case to a sealed public type, what makes the rollout safe?Treat it as a major version, ship the library and its consumers together where they share a release unit, and make sure no consumer has a silent catch-all that would swallow the new case. Where consumers are external, publish the addition with the recompilation requirement stated explicitly, and if the runtime failure mode matters, consider unsealing that branch or introducing a parallel type rather than mutating the promised set.
saying these in an interview costs you the question
- Saying adding a case is "just a recompile" without distinguishing source from binary compatibility.
- Writing a silent default arm to satisfy a non-exhaustive marker, turning a compile-time proof into an unnoticed behaviour change.
- Believing Java or Kotlin have an equivalent of Rust's `#[non_exhaustive]`.
- Sealing business-category enums such as payment methods that are certain to grow.