Java sealed classes can turn a missed switch case into a runtime error
Java 17 introduced sealed classes, which let you list explicitly the allowed sub-types of an interface or base class. The post opens with a toy example: a sealed interface SealedType with two records, TypeA and TypeB, plus a factory method that maps the strings "A" and "B" to them. Inner classes are implicitly added to the permitted sub-types if no explicit list is given. The author compares this to a datatype declaration in Haskell or ML.
A simple Main class then uses a switch expression over the value with a case for each record and no default clause. That works because sealed classes (and enums) enable exhaustiveness checking: the compiler knows every possible case and can verify that all are covered. Forget one, with no default clause, and you get a compile-time error.
The catch is a new kind of breaking change. Adding a sub-type to a sealed class or interface may break consumers of that code. In the example, adding a TypeC case makes the main method fail to compile because its switch no longer covers everything. So if a sealed type is exported in an API, adding a new subtype is a breaking change that would require a major version bump, if you follow SemVer.
The second half of the post asks whether the failure is a compile-time or a runtime error. Java reports the non-exhaustive switch only when the consumer is compiled. If the sealed type changes and the consumer is not recompiled, the compiler cannot help. The author demonstrates it: extend SealedType with a TypeC record and a matching "C" case in the factory, recompile only SealedType.java, and leave Main alone. Running java Main C then throws java.lang.MatchException at Main.main. The author calls it the new MatchException and notes that the Javadoc mentions this potential issue with separate compilation, along with some corner-cases involving nulls in patterns. Anyone hoping sealed classes would statically ensure that every consumer is updated when a case is added is out of luck unless everything is recompiled.
The author's conclusion, offered as a personal view ("I think"), is that sealed types are probably most useful within the implementation of a component, and less useful when exposed in the public API a component offers to others, such as a library. Internally you typically recompile everything together, so you get exhaustiveness checking and stronger compile-time safety. Across module boundaries you may just be introducing new ways to break code, often detectable only at runtime.
The author says these subtleties came up while reviewing the preview support for PEM-encoded cryptographic objects. In the author's view, that API makes exactly this mistake: it bakes a sealed interface into a public API and recommends that clients pattern match against it. The author predicts a very high chance of breakage if its maintainers ever want to add a new case.
Key facts
- Java 17 introduced sealed classes, which explicitly list the allowed sub-types of an interface or base class; a switch over them needs no default clause.
- Forgetting a case in a switch with no default clause is a compile-time error, so adding a subtype such as TypeC breaks consumers that are recompiled.
- If a consumer is not recompiled after the sealed type changes, running the new case throws java.lang.MatchException at runtime instead.
- For a sealed type exported in an API, adding a subtype is a breaking change needing a major version bump under SemVer.
- The author thinks sealed types are probably most useful inside a component's implementation, and cites the preview PEM-encoded cryptographic objects API as a public API that bakes in a sealed interface.
Why it matters
Exhaustiveness checking is the main selling point of sealed types: the compiler tells you when a switch misses a case. The post points out that this guarantee holds only when the consumer is compiled against the current version of the sealed type. Where code is compiled separately, the safety net can turn into a MatchException at runtime. The author also frames it as a new kind of breaking change that library maintainers have to account for.
Who it affects
Mainly library and API authors who think about exposing a sealed interface or class to other components, and the developers who consume those APIs and write switch expressions over them. Teams that compile all of their code together are far less exposed, according to the author.
How to use it
The author's takeaway is to use sealed types inside the implementation of a component, where everything is typically recompiled together and you get exhaustiveness checking and higher compile-time safety guarantees. If you do export a sealed type in an API, treat adding a new subtype as a breaking change that calls for a major version bump under SemVer. Consumers should recompile when the sealed type changes.
How solid is it
The mechanism is shown with a small, concrete code example and the resulting stack trace, and the author notes that the Javadoc itself mentions the separate-compilation issue and some corner-cases with nulls in patterns. The conclusion about where sealed types fit best is the author's own opinion, hedged with "I think" and "probably". The examples are toy code.
Risks and caveats
The advice is a judgment call, not a rule: the post does not recommend a concrete alternative design for public APIs beyond avoiding exposed sealed types. The prediction of breakage for the PEM-encoded cryptographic objects preview API is the author's forecast. The post does not say in which Java version that API ships or in which version MatchException was introduced.
“sealed types are probably most useful within the implementation of a component”
— From the post on neilmadden.blog