When would you choose a sealed interface over a Java enum, an open class hierarchy, or a visitor pattern - and what evolution risks does sealing introduce?
answer
- enum = same-shape constants; sealed = different-shaped cases (records)
- Both give exhaustive switch
- Open hierarchy when outsiders must extend; sealed when you own all cases
- Sealed replaces visitor with less boilerplate
- Adding a subtype is a deliberate breaking change for exhaustive switches
basics
~20 sUse a sealed interface when you have a fixed set of distinct shapes that each carry different data - it gives exhaustiveness like an enum but lets each case have its own fields (often as records). Adding a case is a deliberate change that intentionally breaks exhaustive switches.
solid answer
~50 sAn enum models a fixed set of singleton constants that all share the same shape; a sealed interface models a fixed set of *distinct types* that can each carry different data and behavior, typically as records. Choose sealed when the alternatives differ structurally (Loading vs Loaded(data) vs Failed(error)) and you want compiler-checked exhaustive switches with pattern matching - this replaces the classic visitor pattern with far less boilerplate. Choose an open hierarchy when third parties must be able to add their own subtypes (sealing forbids that unless you expose a non-sealed branch). The main evolution risk is that the permitted set becomes a contract: adding a subtype deliberately breaks every exhaustive switch that lacks a default - which is good for code you own (the compiler points you to each site) but a breaking change for published APIs. Across module boundaries, sealing also forces consumers' implementations to live in your module.
go deeper
Knows an enum is for fixed constants and a sealed interface is for a fixed set of related types, and that both restrict the set.
Chooses sealed when cases carry different data (often records) versus enum for same-shape constants, and knows both enable exhaustive switch.
Weighs sealed vs open vs visitor, understands that adding a subtype breaks exhaustive switches, and uses non-sealed deliberately for controlled extension.
Treats the permitted set as a versioned API contract: reasons about source/binary compatibility, module coupling, when default branches are appropriate, and migration from visitor-based designs across a large codebase.
## The design choice Sealed interfaces, enums, open hierarchies, and the visitor pattern all express 'a value is one of several kinds.' They differ in **what each kind can hold** and **who may add kinds**. ### Sealed interface vs `enum` - An **`enum`** is a fixed set of **named singleton constants**, all of the **same type/shape**. Great for `RED, GREEN, BLUE` or `MONDAY..SUNDAY`. Each constant can have fields, but every constant shares the same field schema. - A **sealed interface** is a fixed set of **distinct types**, each able to carry **different data and behavior**. `Loading` (no data), `Loaded(List<Item> items)`, `Failed(Throwable cause)` have *different* shapes - an enum cannot express that cleanly. Both give a **closed set** and thus **exhaustive `switch`**. Rule of thumb: same shape -> `enum`; different shapes per case -> **sealed interface (usually with records)**. ### Sealed interface vs open hierarchy - An **open** (plain) class/interface lets **anyone, including third parties, add subtypes**. Use it when extensibility by outside code is a *feature* (plugins, SPI). The cost: no exhaustiveness, you can never enumerate all implementations. - **Sealed** trades that openness for a **known, closed set**. Use it when *you* own all the cases and want the compiler to police completeness. If you need a controlled middle ground, mark one branch `non-sealed` to offer a deliberate extension point while keeping the rest closed. ### Sealed interface vs visitor pattern The **visitor pattern** was the pre-Java-17 idiom for exhaustive handling of a closed hierarchy: an interface with one `visitX` method per subtype, and each subtype implementing `accept`. It is verbose and adding a case forces edits across the visitor interface and every visitor. **Sealed interfaces + `switch` pattern matching** achieve the same compile-time exhaustiveness with **far less boilerplate** and no `accept`/`visit` plumbing. In modern Java, sealed + switch is generally preferred over hand-rolled visitors for closed data. ## Evolution risks of sealing 1. **The permitted set is a contract.** Once published, the list of subtypes is part of your type's API. Consumers may write exhaustive switches relying on it. 2. **Adding a subtype is a (deliberate) breaking change.** Every exhaustive `switch` **without** a `default` that doesn't handle the new case **fails to compile**. For code *you* control this is a feature - the compiler hands you a worklist. For a *published* library, it can break downstream code, so it is a source/binary-compatibility consideration. (A `default` branch absorbs new cases but **discards the compiler's exhaustiveness help** - a real trade-off.) 3. **Removing a subtype** is also breaking (consumers referencing it won't compile). 4. **Module coupling.** Because permitted subtypes must live in the sealed type's module/package, you cannot let consumers in *other* modules contribute implementations - sealing centralizes the hierarchy in your module by design. 5. **`non-sealed` leaks closedness.** Exposing a `non-sealed` branch reopens extension but **destroys exhaustiveness** for that branch - switches then need a `default` or a catch-all, weakening the guarantee. ## Decision guide | Need | Pick | |---|---| | Fixed constants, same shape | `enum` | | Fixed set, each case different data | **sealed interface + records** | | Third parties must add subtypes | open hierarchy (or `non-sealed` branch) | | Exhaustive handling, modern Java | sealed + `switch` pattern matching (over visitor) | ## Terms defined - **Exhaustiveness:** the compiler can prove all cases are handled. - **Pattern matching for switch:** `case Loaded l ->` / record-deconstruction `case Loaded(var items) ->` introduced for switch in recent Java. - **SPI:** Service Provider Interface - an intentionally open extension point. - **Binary/source compatibility:** whether a change breaks already-compiled / to-be-recompiled downstream code.
- Why is sealed + switch often preferred over the visitor pattern today?It gives the same compile-time exhaustiveness with far less boilerplate - no accept/visit methods - and adding a case still surfaces every switch site that must be updated, via compile errors.
- How does adding a default branch interact with sealed exhaustiveness?A default makes the switch compile even when new subtypes are added, but it discards the compiler's help: you no longer get a compile error pointing you to handle the new case, so you trade safety for resilience.
- What is the cost of marking a branch non-sealed?It reopens that branch to arbitrary extension, which restores extensibility but breaks exhaustiveness for that subtree - switches must then carry a default/catch-all.
saying these in an interview costs you the question
- Reaching for an enum when the cases carry different data - that forces awkward shared fields or null
- Treating sealed as fully open/extensible by third parties - permitted subtypes must be in your module
- Adding a default to every switch and thereby silently losing exhaustiveness checking
- Assuming adding a permitted subtype is a safe, non-breaking change for published APIs
- Recommending the visitor pattern over sealed+switch in modern Java without justification