Some languages let a type declare that only a fixed, known set of types may extend or implement it — Java's `sealed ... permits`, Kotlin's `sealed class`, Scala 3's `sealed trait`, Rust's `enum`. What does a compiler gain from that guarantee, and where do these languages actually draw the boundary of "known"?
answer
- closed set -> exhaustive match, no default arm
- Rust variant is not a type; Java permitted case is
- boundary: type / file / package+module
- Java re-checks permits at class load, not just javac
- final - sealed - open is a spectrum, per node
basics
~20 sIt gains exhaustiveness: the compiler can enumerate the cases, so a match needs no default branch. Where "known" ends differs — the type itself in Rust and Haskell, the file in Scala 3, the package plus module in Java and Kotlin.
solid answer
~50 sClosure buys **exhaustiveness**. The compiler can enumerate the cases, so a match needs no default arm — and adding a case becomes a compile error at every match site instead of a silent fallthrough. The boundary of "known" is where languages genuinely diverge: - **Rust / Haskell / OCaml**: closure is intrinsic — variants are declared *inside* the type, so nothing outside can ever add one. The price: `Shape::Circle` is a constructor, not a type, so no signature can require just that variant. - **Java** (17+): the `permits` list, with permitted types confined to the same module (same package in the unnamed module). Each case is a real class, usable in signatures — and the JVM re-checks the permits list at class load, not just `javac`. - **Kotlin** (1.5+): same package *and* module; compile-time only. - **Scala 3**: same file. And sealing is per-node, not per-tree: Java's `non-sealed` deliberately reopens one branch.
code
text · 11 linesclosed type Shape = Circle(r) | Square(a)
function area(s: Shape):
match s:
Circle(r) -> PI * r * r
Square(a) -> a * a
// no default arm needed: the compiler proves the match is total
// add `| Triangle(b, h)` to Shape
// -> every match over Shape that lacks a Triangle arm
// fails to compile, pointing at each sitego deeper
Recall the core trade: a closed set of subtypes lets the compiler check a match covers every case, so you can drop the default branch and get a compile error when someone adds a case.
Name the boundary for at least two languages (Rust closes at the type, Java at the module via permits, Kotlin at package plus module, Scala 3 at the file) and know that in Java each case is a real class while in Rust a variant is not a type.
Frame it as a design decision: sealing declares the domain complete, so choose it when cases are fixed and operations grow; mention non-sealed as the per-node escape valve and that Java's permits list is enforced by the JVM at class load, not only by the compiler.
Argue about where the boundary should sit for a codebase — file vs module scoping as a readability and ownership call, sealing as a statement about who is allowed to author cases, and the point at which an open extension surface serves the system better than a provably total one.
## What "closed" means A hierarchy is **closed** (sealed) when the set of types that may directly extend or implement a parent is fixed and knowable to the compiler at the point the parent is declared. **Open** is the opposite promise: anyone, in any future compilation unit, may add a subtype. Most object-oriented languages default to open — unbounded extension is what inheritance was sold on. Sealing is the deliberate refusal of that default, and the refusal buys something concrete. ## What the compiler gains The headline gain is **exhaustiveness** (totality) checking. If the compiler knows the case set is `{A, B, C}`, it can verify that a match over the parent handles all three and reject the code otherwise. Three consequences follow. 1. **The default arm disappears.** In an open hierarchy a `default` / `else` branch is mandatory, and it is precisely the branch that silently swallows every case someone adds later. Closure converts a silent swallow into a compile error. 2. **Adding a case becomes a guided refactor.** The compiler lists every site that must change. This is the property people actually buy sealing for, and it is why interpreters, protocol decoders and state machines are written this way. 3. **Narrowing and derivation get easier.** The compiler can refine the parent type inside each arm without a cast, and generated code (deriving, pattern-match compilation) can reason over the whole set. Smaller gains: a closed set of implementations lets an optimiser devirtualise calls, and a reader can enumerate the whole domain from one declaration. ## The algebraic flavour — and why a variant is not always a type A closed hierarchy is a **sum type** (tagged union): a value is exactly one of N alternatives, each with its own payload. An open hierarchy is the dual, tuned for adding alternatives rather than for consuming them. Nearly every modern language now spells sums somehow: Rust/Swift `enum`, OCaml/F#/Haskell variants, Java sealed interfaces plus records, Kotlin sealed classes, Scala 3 `enum` (sugar for a sealed trait plus cases). The spellings are *not* equivalent, and this is where candidates get caught. In Rust, OCaml and Haskell a **variant is not a type**: `Shape::Circle` names a constructor, not something a function signature can demand. If you need "a circle" as a parameter type you introduce a separate struct and wrap it in the variant. In Java and Kotlin each permitted case **is** a class: it can appear in a signature, hold its own state, have its own subtypes, and even be reopened. So Java gives you sums and products in one hierarchy with finer type-level granularity; Rust gives you a tighter, provably total sum with less. ## Where the boundary of "known" sits The boundary is always chosen so that a *separate* compilation cannot sneak a case in — but the unit differs. - **Rust / Haskell / OCaml (ordinary variants)**: the type declaration itself. Cases are written inside it; closure is intrinsic and total, with no modifier to opt out of. - **Scala 3**: `sealed` means **same file**. The tightest boundary among the class-based languages, and the reason large Scala hierarchies live in one long file. - **Kotlin** (1.5+): **same package and same module**. Before 1.5 it was same-file, and sealed *interfaces* did not exist. Enforcement is compile-time only. - **Java** (sealed classes standard in 17): the explicit `permits` clause, with permitted types required to be in the same module (or same package in the unnamed module). Two extra teeth: every permitted type must itself be `final`, `sealed`, or explicitly `non-sealed`; and the permits list is recorded in the class file and **re-checked by the JVM at class load**, so sealing is not merely a `javac` convention. - **OCaml polymorphic variants**: the deliberate opposite — a structurally typed, re-openable set where each function declares the subset it handles and no single declaration owns the whole. Useful when you want extensibility more than totality. The width of the boundary is a real ergonomics decision: a file-scoped boundary keeps the domain readable in one place; a module-scoped one lets each case live in its own file with its own tests, at the cost of having to search a package to enumerate the domain. ## final / sealed / open is a spectrum Think of three points, not two. `final` permits zero extension, `sealed` permits an enumerated set, `open` permits anything. Defaults are themselves a design statement: Kotlin and C# make classes non-extensible by default and require an opt-in; Java and C++ default to extensible. Crucially, sealing is a **per-node** property, not a whole-tree one. Java's `non-sealed` modifier lets a closed tree carry one deliberately open branch — the top stays exhaustively matchable while one case admits third-party subtypes. Rust reaches the same shape by different means: a catch-all variant holding a trait object, which reopens the set at exactly one point and forces every match to handle "something else". ## The cost to name Sealing is a **public API commitment** — you are asserting the case set is complete — and what happens when you later break that assertion belongs to the versioning discussion, not here. ## Interview shape Say what closure buys (exhaustiveness plus compiler-guided change), name at least two languages that draw the boundary differently, add the variant-is-not-a-type point, and finish on the spectrum: `final`, `sealed`, `open`, chosen per node.
- Java lets a permitted subtype be declared `non-sealed`. What is that modifier for, and how would you express the same idea in a language like Rust where closure is intrinsic to the type?`non-sealed` reopens exactly one branch of an otherwise closed tree: the root still enumerates its permitted cases, but that one case may be extended by anyone, including code outside the module. It makes sealing a per-node property rather than a whole-tree one — useful when most of the domain is fixed but one case is a genuine extension point. Rust has no modifier for it, so you reach the same shape with a catch-all variant carrying a trait object (`Other(Box<dyn Shape>)`), which reopens the set at one point and forces every match to handle "something else" explicitly.
- Kotlin relaxed sealed classes from same-file to same-package-and-module in 1.5, while Scala 3 still closes at the file. Why does the width of that boundary matter in practice?A file-scoped boundary keeps the entire domain readable in one place, which is exactly what you want for a small sum type, but it forces a single enormous file once the hierarchy has twenty cases with real behaviour. A package-or-module boundary lets each case live in its own file with its own tests and helpers, at the cost that enumerating the domain now means searching a package rather than scrolling one file. Either way the guarantee is the same strength inside one build; the choice is about where you pay the readability cost.
- If the compiler can enumerate the cases anyway, when is an open hierarchy still the better design?Open hierarchies are tuned for the opposite kind of change: adding a new implementation touches one new file and nothing else, whereas in a closed hierarchy it touches every match site. So seal when the set of cases is genuinely fixed by the domain (AST nodes, protocol frames, state-machine states) and the operations over them keep growing; stay open when third parties are meant to contribute implementations and the operation set is stable — a plugin or strategy surface. Sealing something whose cases you expect outsiders to extend just converts extension into a fork.
A sealed hierarchy is a ballot with the candidates printed on it: counting can be proved complete. An open hierarchy is a write-in ballot — you must always keep an "other" pile, and that pile is where the surprises accumulate.
saying these in an interview costs you the question
- Saying sealed means the same as final — final permits zero extension, sealed permits an enumerated set.
- Claiming exhaustiveness is enforced at run time by rejecting unknown subtypes; the check is a compile-time proof over a known case set.
- Assuming a Rust or OCaml variant can be used as a parameter or field type — it is a constructor, not a type.
- Treating all sealed constructs as identical, missing that the boundary is the type in Rust, the file in Scala 3, and the package plus module in Kotlin.
- Insisting a default/else branch is harmless defensive coding, when it is precisely what silently swallows cases added later.