Software Engineering
Java Sealed Types: Keep Variant Changes Visible to Callers
Use sealed Java types to model closed variants, make missing cases visible during compilation, and test compatibility when a shared hierarchy evolves.
In this article
Java sealed types are most useful when a domain has a deliberately bounded set of variants. They let a library say which direct subclasses or implementations belong to a hierarchy. The benefit is not shorter syntax alone: callers can make deliberate decisions about every allowed case instead of relying on strings, unchecked casts, or an accidental fallback.
The Java Language Specification defines the relationship between sealed, final, and non-sealed classes. The example below targets Java 21 language features. Test it with your actual compiler release rather than assuming a newer runtime makes an older source configuration understand pattern switches.
Model a result, not a collection of flags
Imagine a document parser that can produce a completed document or a validation problem. Two booleans such as valid and hasDocument permit contradictory states. A closed result hierarchy makes those alternatives explicit:
sealed interface ParseResult permits Parsed, Rejected {}
record Parsed(String documentId) implements ParseResult {}
record Rejected(String reason) implements ParseResult {}
final class ResultView {
static String describe(ParseResult result) {
return switch (result) {
case Parsed p -> "Document: " + p.documentId();
case Rejected r -> "Rejected: " + r.reason();
};
}
}Place this example in one compilation unit in the same package. It contains no public top-level type, so the filename need not match one of these names. Compile using a Java 21 toolchain with javac --release 21 ResultView.java.
The records carry the data relevant to their alternatives. This is more expressive than an enum when each case needs different fields. An enum remains simpler when alternatives have no distinct payload.
Use exhaustiveness as a change detector
Now add a Deferred result carrying a retry time, include it in the permitted hierarchy, and rebuild the caller. The switch should need a new case. That compiler failure is useful: it identifies a business decision that has not yet been made.
Should the interface display “pending,” offer a retry button, or refuse the operation? A default branch returning “unknown” would hide that question. For code maintained and released together, explicit cases often create a better review boundary than a blanket fallback.
Do not confuse this with protection against every runtime failure. Inputs can still violate your assumptions, methods can throw, and separately deployed binaries may not evolve together. Compile-time completeness applies to the types and language rules visible during compilation.
Decide whether null belongs in the contract
A sealed hierarchy does not make references non-null. In this example, require a non-null result at the caller boundary or handle null intentionally. Do not treat a successful compilation as proof that every possible value from a database or external API is valid.
If a missing result is meaningful, model it as a named alternative instead of relying on an undocumented null. That lets reviewers distinguish “no document exists” from “the producer forgot to return a result.” Keep malformed payload validation separate from variant selection.
For example, reject a blank document identifier when constructing a successful result if your application requires a real identifier. A closed type family does not validate the contents of a string automatically.
Understand the non-sealed escape hatch
A permitted subclass that is non-sealed deliberately reopens extension beneath itself. That can be appropriate for third-party plugins, but it weakens the idea that every concrete leaf is known. Callers may handle the open branch as one category rather than enumerate every possible extension.
Ask who owns future variants. If all variants represent a business protocol controlled by one team, an open branch may be unnecessary. If external extensions are part of the product contract, closing everything can create needless friction.
Avoid choosing sealed merely to prevent inconvenient inheritance. Document the domain invariant it protects. Otherwise, a future maintainer may reopen the hierarchy to solve a local problem without realizing what callers depended on.
Test separately released consumers
For a shared library, keep a small compatibility fixture that compiles a consumer against the old release. Then run the intended upgrade combination in a test environment. Also rebuild that consumer against the new release so source-level missing cases become visible.
Include serialization boundaries. A new variant may travel as a new JSON discriminator that an old client cannot decode, even before Java pattern matching runs. Define whether that client should reject the message, preserve it as an explicitly unknown representation, or negotiate an older protocol.
Version the wire contract independently when necessary. A sealed Java API is an in-process design tool; it does not by itself create a forward-compatible network format.
Include a deliberate new-variant change in the library’s test fixture. The expected compilation failure documents which callers are supposed to require review when the closed family expands.
Conclusion
Use sealed types when the closed set is a real domain promise. Let compilation expose new-case decisions, validate payloads separately, and test old consumers before changing a hierarchy shared across release boundaries.