The Expression Problem: Why Java’s Sealed Interfaces Are One Answer, Not The Answer
Adding a new type is trivial in object-oriented code. Adding a new operation is trivial in functional code. Nobody has ever gotten both for free, and understanding why explains a lot about modern Java.
Every engineer has felt this friction at some point, usually without a name for it. You add a new subclass to your object hierarchy and everything just works. Then, a few sprints later, someone asks for one new method across that same hierarchy, and suddenly you are touching every single file. Or the opposite happens: you are in a functional codebase, you add a new function in five minutes, and then a new data variant arrives and you are hunting down every pattern match in the project to update it.
This is not bad design. It is a structural limit that programming languages have wrestled with for decades, and it has a name: the Expression Problem.
What Exactly Is the Expression Problem?
The term was coined by Philip Wadler in a message to the Java Genericity mailing list on November 12, 1998, though the underlying tension had already been noticed by John Reynolds back in 1975. Wadler’s own description is still the clearest one available: define a datatype by cases, then try to add both new cases and new functions over that datatype, without recompiling existing code and without giving up static type safety. You can read his original note here, and the Wikipedia summary is a solid companion read if you want the full lineage of the idea.
The cleanest way to picture it is a grid. Picture a table where the rows are the cases of your data, say the different kinds of shapes in a graphics library, and the columns are the operations you run over them, say area, perimeter, and rendering. Filling in that whole table is the actual goal. The catch is that every mainstream language makes one direction of growth cheap and the other one expensive.
| Paradigm | Adding a new row (type) | Adding a new column (operation) |
|---|---|---|
| Object-oriented | Easy: write one new class implementing the interface | Hard: touch every existing class to add the method |
| Functional | Hard: touch every existing pattern match to handle the new case | Easy: write one new function that pattern matches over existing cases |
Why Object-Oriented Code Loves New Types
In a typical interface-plus-implementations design, each class owns its own behavior. A new type just means a new class that fills in the interface’s methods, and the compiler never asks you to revisit the classes that already exist. That is exactly why plugin architectures, drivers, and strategy patterns feel so natural in OO languages. The interface is the contract, and anyone can satisfy it from the outside.
The cost shows up the moment someone wants a new operation across that whole family of types. Since behavior lives inside each class, adding one more method means opening every class file that implements the interface, one by one, and there is no compiler nudge reminding you which ones you missed unless the interface itself is abstract and forces every implementer to comply.
Why Functional Code Loves New Operations
Functional languages flip the ownership. Data is defined once, as a closed set of cases, and functions live outside the data as separate, independent units. Adding a new operation is just writing one new function that pattern matches across the existing cases. Nothing about the data type changes, so nothing else in the codebase needs to move.
The mirror-image cost appears when a brand-new case shows up. Because every existing function pattern matches over a fixed, closed set of cases, each of those functions needs a new branch, and in a language with exhaustiveness checking, the compiler will at least tell you every place you forgot. Without that checking, a missed case just becomes a silent runtime surprise.
The Classic Escape Routes
Long before Java had sealed types, people were already inventing workarounds. None of them are free, and that is the whole point of the problem: every solution trades one kind of extensibility for another, or trades simplicity for extra ceremony.
| Approach | Idea | What it gives up |
|---|---|---|
| Visitor pattern (GoF, 1994) | Double dispatch through an accept method, so new operations become new Visitor implementations | Adding a new type still means updating the Visitor interface and every existing visitor |
| Type classes (Haskell) | Attach behavior to a type from outside, independent of where the type or the function was defined | Requires a language runtime built around this from day one; hard to retrofit |
| Object algebras (Oliveira and Cook) | Encode the datatype as a generic interface of constructors, letting both axes extend independently | More abstract, harder to read for teams unfamiliar with the technique |
| Java sealed interfaces plus exhaustive switch | Close the type hierarchy on purpose, then let the compiler enforce exhaustive handling in every switch | Third parties cannot add new cases at all; the hierarchy is closed by design |
Java’s Answer: Close the World on Purpose
This is where Java’s sealed interfaces and exhaustive switch, which we have covered in more depth in an earlier piece, fit into this much older story. Java does not try to give you both axes of extensibility at once. Instead, it makes a deliberate choice: for a given hierarchy, you decide up front whether it should behave like an open, OO-style family that anyone can extend, or a closed, FP-style family where the compiler can guarantee every switch handles every case.
A sealed interface lists exactly which classes are allowed to implement it. Once that list is fixed, the compiler has enough information to check, at compile time, that a switch over that interface covers every permitted case. Miss one, and the build fails instead of the bug shipping.
// Shapes.java — compiles and runs on JDK 21 or newer, no preview flags needed
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
public class Shapes {
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
};
}
public static void main(String[] args) {
System.out.println(area(new Circle(2)));
System.out.println(area(new Square(3)));
}
}
// Compile and run with:
// javac Shapes.java
// java Shapes
Add a new record, say Triangle, to the permits clause, and every switch like the one above stops compiling until you add a matching case. That is the functional-style guarantee, borrowed into an object-oriented language, but only for hierarchies you choose to close. Anything still marked as a plain, non-sealed interface keeps the original OO behavior where third parties can extend it freely.
This trade did not appear overnight. Getting exhaustiveness checking right against sealed hierarchies took Java noticeably longer than most other recent language features, which is a useful signal of how genuinely hard this problem is to resolve cleanly.
Choosing Sides Deliberately
None of this means sealed types are always the right call. They are a good fit when you know, with reasonable confidence, that the full set of cases will not need external extension, think HTTP methods, payment states, or the nodes of a small parsing grammar. They are a poor fit when you are building a library and you actually want strangers to add their own implementations, in which case a plain, open interface is still the better tool.
Quick Checklist: Sealed Hierarchy or Open Interface?
- Do you know, and control, every case that will ever exist? Lean sealed.
- Do external teams or plugins need to add their own implementations? Lean open interface.
- Do you add new operations far more often than new cases? Sealed plus switch pays off fastest here.
- Do you add new cases far more often than new operations? A classic OO interface will cause less churn.
- Is compile-time exhaustiveness worth the rigidity of a closed hierarchy for this specific model? If yes, seal it.
What We Learned
The Expression Problem is not a Java problem, and it did not start in 2021. It is a decades-old tension between two natural ways of organizing code: grouping behavior by type, which is what object orientation does, and grouping behavior by operation, which is what functional pattern matching does. Each approach makes one kind of change trivial and the other one expensive, and no mainstream language has ever made both directions equally cheap for free. Java’s sealed interfaces and exhaustive switch are a genuinely elegant answer, but they are one specific answer among several, and they only apply where you are comfortable closing the door on future, unknown extensions. Knowing that trade-off going in is what separates a deliberate design choice from an accidental one.



