Sealed Classes and Pattern Matching in Java
A sealed type names exactly which types may extend it, which lets the compiler check that a pattern switch covers every case.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
Sealed types
public sealed interface Shape permits Circle, Rectangle, Triangle { }
public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }A sealed type restricts which types may extend or implement it. Finalised in Java 17, it fills the gap between final, which allows nothing, and an open type, which allows anything.
| Modifier | Who may extend |
|---|---|
final | Nobody |
sealed | Only the permitted types |
| Neither | Anyone |
What each permitted subtype must declare
public sealed class Payment permits CardPayment, UpiPayment, WalletPayment { }
public final class CardPayment extends Payment { } // closed
public sealed class UpiPayment extends Payment
permits VerifiedUpiPayment { } // sealed further
public non-sealed class WalletPayment extends Payment { } // reopenedEvery permitted subtype must be final, sealed or non-sealed. The compiler forces the decision, so the hierarchy is never accidentally left open.
Other rules
- Permitted subtypes must be in the same package, or the same module if named.
- The
permitsclause can be omitted when all subtypes are in the same file. - Records are implicitly
final, which makes them natural members of a sealed hierarchy.
// permits is inferred here
public sealed interface Result {
record Success(String value) implements Result { }
record Failure(String message) implements Result { }
}Exhaustive pattern switch
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> t.base() * t.height() / 2;
}; // no default needed
}This is the real payoff. BecauseShapeis sealed, the compiler knows the complete list. Add a fourth shape and every switch that has not been updated fails to compile, pointing straight at the work remaining. Adefaultbranch would silence that, which is why you should not add one.
Record patterns
static double area(Shape shape) {
return switch (shape) {
case Circle(double radius) -> Math.PI * radius * radius;
case Rectangle(double width, double h) -> width * h;
case Triangle(double base, double h) -> base * h / 2;
};
}A record pattern destructures the components directly, so no accessor calls are needed. Introduced in Java 21, and it nests to any depth.
sealed interface Node permits Leaf, Branch { }
record Leaf(int value) implements Node { }
record Branch(Node left, Node right) implements Node { }
static int sum(Node node) {
return switch (node) {
case Leaf(int value) -> value;
case Branch(Node left, Node right) -> sum(left) + sum(right);
};
}Guards
static String describe(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "a very large circle";
case Circle c -> "a circle";
case Rectangle(double w, double h) when w == h -> "a square";
case Rectangle r -> "a rectangle";
case Triangle t -> "a triangle";
};
}A when clause adds a condition to a pattern. Order matters: guarded cases must come before the unguarded case for the same type, or the compiler reports the later one as unreachable.
null
static String describe(Shape shape) {
return switch (shape) {
case null -> "nothing";
case Circle c -> "circle";
case Rectangle r -> "rectangle";
case Triangle t -> "triangle";
};
}Without an explicit case null, a null selector throws NullPointerException. That default was chosen to match the older behaviour of switch.
A worked example
public sealed interface Command
permits Publish, Archive, Rename { }
public record Publish(long noteId, LocalDate on) implements Command { }
public record Archive(long noteId, String reason) implements Command { }
public record Rename(long noteId, String title) implements Command { }
public String apply(Command command) {
return switch (command) {
case Publish(long id, LocalDate on) when on.isAfter(LocalDate.now()) ->
"note " + id + " scheduled for " + on;
case Publish(long id, LocalDate on) ->
"note " + id + " published on " + on;
case Archive(long id, String reason) ->
"note " + id + " archived: " + reason;
case Rename(long id, String title) ->
"note " + id + " renamed to " + title;
};
}Sealed types compared with polymorphism
| Sealed plus pattern switch | Abstract method | |
|---|---|---|
| Adding a subtype | Every switch fails to compile | Only the new class is written |
| Adding an operation | One new method in one place | Every subtype must be edited |
| Behaviour lives | Outside the data | Inside each type |
| Suits | A closed set of data shapes | An open set of behaviours |
The two approaches trade the same difficulty in opposite directions. Use polymorphism when new types arrive often, and a sealed hierarchy when new operations arrive often over a fixed set of shapes.
Common mistakes
- Adding a
defaultto a sealed switch, which discards the exhaustiveness check. - Forgetting to mark a permitted subtype
final,sealedornon-sealed. - Placing a permitted subtype in a different package.
- Putting an unguarded case before a guarded one for the same type.
- Forgetting
case nullwhen the value can be null. - Sealing a hierarchy that genuinely needs to be extensible.
Best practices
- Seal a hierarchy when the set of alternatives is genuinely closed, such as commands, results or states.
- Prefer records as the permitted subtypes.
- Never add
defaultto a sealed switch. - Use record patterns to destructure rather than calling accessors.
- Keep guards simple and ordered from most to least specific.
Practice
- Model a payment result as a sealed interface with success and failure records, and handle both in a switch.
- Add a fourth shape and observe which switches now fail to compile.
- Why is a
defaultbranch harmful in a sealed switch? - Write a nested record pattern that reads two levels deep.
- Give one example where polymorphism would be the better choice.
Conclusion
Sealing a hierarchy tells the compiler the full list of alternatives, and pattern matching then lets it prove that every one is handled. Together they turn a whole class of missing case bugs into compile errors.