Sealed types with pattern matching
Sealed interfaces say which cases exist, records hold the data of each case, and a pattern switch handles every case while the compiler checks that none is missing. Together they are often called data-oriented programming. This page explains when that style beats ordinary polymorphism, then builds an expression simplifier, compares it with the visitor pattern, writes a state machine, and shows null handling and the run-time safety net.
Contents
Data-oriented programming: the idea
Object-oriented design puts behaviour inside the types: each shape has its own area()
Data-oriented programming separates the two. The data is modelled as plain records, the set of alternatives is closed with a sealed interface, and each operation is a single method containing an exhaustive switch
- Sealed types tell the compiler every possible subtype.
- Record patterns take a value apart, at any depth, without casts.
- Exhaustiveness checking rejects a switch that misses a case, so adding a subtype points you to every operation that must handle it.
This tension, between adding types easily and adding operations easily, is known as the expression problem. No single design wins both ways, so the table compares the options.
| Approach | Since | Adding a type | Adding an operation | Best for |
|---|---|---|---|---|
| Polymorphic method in each class | Java 1.0 | Easy: one new class | Touches every class | Open hierarchies, plugins |
| Visitor pattern | Java 1.0 (design pattern) | Touches every visitor | One new visitor class | Closed sets before Java 21 |
instanceof | Java 1.0 | Silently missed | One new method | Avoid: nothing checks completeness |
| Sealed types + pattern switch | Java 21 | Compiler lists every switch to fix | One new method | Closed sets with many operations |
History: sealed classes became standard in Java 17 (JEP 409). Pattern matching for switch
An expression tree as sealed records
An expression is a number, a variable, an addition, a multiplication or a negation, and nothing else. Each kind is a record implementing the sealed interface ExprAddExpr
import java.util.Map;
public class ExprEvaluator {
sealed interface Expr permits Num, Var, Add, Mul, Neg {
}
record Num(int value) implements Expr {
}
record Var(String name) implements Expr {
}
record Add(Expr left, Expr right) implements Expr {
}
record Mul(Expr left, Expr right) implements Expr {
}
record Neg(Expr operand) implements Expr {
}
static int eval(Expr e, Map<String, Integer> env) {
return switch (e) {
case Num(int v) -> v;
case Var(String name) -> env.get(name);
case Add(Expr l, Expr r) -> eval(l, env) + eval(r, env);
case Mul(Expr l, Expr r) -> eval(l, env) * eval(r, env);
case Neg(Expr inner) -> -eval(inner, env);
};
}
static String show(Expr e) {
return switch (e) {
case Num(int v) -> Integer.toString(v);
case Var(String name) -> name;
case Add(Expr l, Expr r) -> "(" + show(l) + " + " + show(r) + ")";
case Mul(Expr l, Expr r) -> show(l) + " * " + show(r);
case Neg(Expr inner) -> "-" + show(inner);
};
}
// One rewriting step per case; nested patterns and guards pick out the special shapes
static Expr simplify(Expr e) {
return switch (e) {
case Add(Num(int a), Num(int b)) -> new Num(a + b);
case Mul(Num(int a), Num(int b)) -> new Num(a * b);
case Add(Expr x, Num(int zero)) when zero == 0 -> simplify(x);
case Mul(Expr x, Num(int one)) when one == 1 -> simplify(x);
case Mul(Expr x, Num(int zero)) when zero == 0 -> new Num(0);
case Neg(Neg(Expr x)) -> simplify(x);
case Add(Expr l, Expr r) -> rebuildAdd(simplify(l), simplify(r), e);
case Mul(Expr l, Expr r) -> rebuildMul(simplify(l), simplify(r), e);
case Neg(Expr x) -> new Neg(simplify(x));
case Num n -> n;
case Var v -> v;
};
}
static Expr rebuildAdd(Expr l, Expr r, Expr original) {
Expr next = new Add(l, r);
return next.equals(original) ? next : simplify(next);
}
static Expr rebuildMul(Expr l, Expr r, Expr original) {
Expr next = new Mul(l, r);
return next.equals(original) ? next : simplify(next);
}
public static void main(String[] args) {
// (x + (2 * 3)) * 1 + 0, with a double negation around y
Expr e = new Add(
new Mul(new Add(new Var("x"), new Mul(new Num(2), new Num(3))), new Num(1)),
new Add(new Neg(new Neg(new Var("y"))), new Num(0)));
Map<String, Integer> env = Map.of("x", 4, "y", 5);
Expr simple = simplify(e);
System.out.println("original: " + show(e));
System.out.println("simplified: " + show(simple));
System.out.println("value before: " + eval(e, env) + ", after: " + eval(simple, env));
}
}
Output:
original: ((x + 2 * 3) * 1 + (--y + 0))
simplified: ((x + 6) + y)
value before: 15, after: 15
became 6 through2 * 3 , a nested pattern that matches only a product of two literals, at any depth.Mul(Num(int a), Num(int b))- The
and* 1 disappeared through guards such as+ 0 . Patterns test types and structure; comparing with a value needswhen one == 1 .when became--y throughy .Neg(Neg(Expr x))- Both versions evaluate to 15, which is the check that the rules preserve the meaning.
- Cases are tried top to bottom. The specific rules come first and the general
case after them.Add(Expr l, Expr r) simplifies again only if something changed, so the recursion stops.rebuildAdd - The last two cases,
andNum n , are required: without them a plain number would match nothing and the compiler rejects the switch.Var v
The visitor pattern compared with a pattern switch
Before Java 21, the type-safe way to write operations over a closed set was the visitor pattern. The program computes the same areas both ways on the same shapes, plus one larger circle, so the code size and readability can be compared directly.
import java.util.List;
public class VisitorVsSwitch {
// 1. Classic visitor: every operation is a class, every shape needs accept()
interface ShapeVisitor<R> {
R visitCircle(VCircle c);
R visitRect(VRect r);
}
interface VShape {
<R> R accept(ShapeVisitor<R> v);
}
record VCircle(double r) implements VShape {
public <R> R accept(ShapeVisitor<R> v) {
return v.visitCircle(this);
}
}
record VRect(double w, double h) implements VShape {
public <R> R accept(ShapeVisitor<R> v) {
return v.visitRect(this);
}
}
static final class AreaVisitor implements ShapeVisitor<Double> {
public Double visitCircle(VCircle c) {
return Math.PI * c.r() * c.r();
}
public Double visitRect(VRect r) {
return r.w() * r.h();
}
}
// 2. Sealed interface and a pattern switch: an operation is one method
sealed interface Shape permits Circle, Rect {
}
record Circle(double r) implements Shape {
}
record Rect(double w, double h) implements Shape {
}
static double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rect(double w, double h) -> w * h;
};
}
static String describe(Shape s) {
return switch (s) {
case Circle c when c.r() > 5 -> "big circle";
case Circle c -> "circle";
case Rect(double w, double h) when w == h -> "square";
case Rect r -> "rectangle";
};
}
public static void main(String[] args) {
List<VShape> visitorShapes = List.of(new VCircle(1), new VRect(2, 3), new VRect(4, 4));
AreaVisitor areaVisitor = new AreaVisitor();
for (VShape s : visitorShapes) {
System.out.printf("visitor: %-28s area %6.3f%n", s, s.accept(areaVisitor));
}
List<Shape> shapes = List.of(new Circle(1), new Rect(2, 3), new Rect(4, 4), new Circle(6));
for (Shape s : shapes) {
System.out.printf("switch: %-28s area %7.3f %s%n", s, area(s), describe(s));
}
}
}
Output:
visitor: VCircle[r=1.0] area 3.142
visitor: VRect[w=2.0, h=3.0] area 6.000
visitor: VRect[w=4.0, h=4.0] area 16.000
switch: Circle[r=1.0] area 3.142 circle
switch: Rect[w=2.0, h=3.0] area 6.000 rectangle
switch: Rect[w=4.0, h=4.0] area 16.000 square
switch: Circle[r=6.0] area 113.097 big circle
- Both give the same areas: 3.142 for the unit circle, 6 and 16 for the rectangles. The results are identical; the difference is in the code.
- The visitor needs a visitor interface with one method per shape, an
method in every shape, and a class per operation. Each shape had to be written knowing that visitors exist.accept - The switch version needs nothing in the records.
andarea are ordinary static methods, anddescribe adds guards (radius above 5, equal sides) that a visitor could only do withdescribe statements inside each visit method.if - Both are checked by the compiler. A new shape breaks the visitor interface's implementations, and it breaks every exhaustive switch. That checking is what an
chain lacks.instanceof
A state machine as a switch over pairs
A state machine decides the next state from the current state and an event. Matching on a pair of both, with nested patterns, makes the switch read like a transition table. The stopwatch runs from 0 to 30, pauses, runs from 50 to 70 and stops, so the expected total is 30 + 20 = 50 seconds; a late start and an illegal pause test the remaining cases.
import java.util.List;
public class StateMachine {
sealed interface State permits Idle, Running, Paused, Done {
}
record Idle() implements State {
}
record Running(long startedAt, long banked) implements State {
}
record Paused(long banked) implements State {
}
record Done(long total) implements State {
}
sealed interface Event permits Start, Pause, Stop {
long at();
}
record Start(long at) implements Event {
}
record Pause(long at) implements Event {
}
record Stop(long at) implements Event {
}
// One case per (state, event) combination that means something; the rest is an error.
// The pair is matched with nested patterns, so the table reads like a transition diagram
static State next(State state, Event event) {
record Step(State s, Event e) {
}
return switch (new Step(state, event)) {
case Step(Idle i, Start(long t)) -> new Running(t, 0);
case Step(Running(long t0, long b), Pause(long t)) -> new Paused(b + t - t0);
case Step(Running(long t0, long b), Stop(long t)) -> new Done(b + t - t0);
case Step(Paused(long b), Start(long t)) -> new Running(t, b);
case Step(Paused(long b), Stop s) -> new Done(b);
case Step(Done d, Event e) -> d; // finished: ignore events
case Step(State s, Event e) ->
throw new IllegalStateException(e + " not allowed in " + s);
};
}
public static void main(String[] args) {
// A stopwatch: run 0-30, pause, run 50-70, stop at 70: 30 + 20 = 50 seconds
List<Event> events = List.of(new Start(0), new Pause(30), new Start(50), new Stop(70),
new Start(90));
State s = new Idle();
for (Event e : events) {
s = next(s, e);
System.out.printf("%-13s -> %s%n", e, s);
}
try {
next(new Idle(), new Pause(5));
} catch (IllegalStateException ex) {
System.out.println("rejected: " + ex.getMessage());
}
}
}
Output:
Start[at=0] -> Running[startedAt=0, banked=0]
Pause[at=30] -> Paused[banked=30]
Start[at=50] -> Running[startedAt=50, banked=30]
Stop[at=70] -> Done[total=50]
Start[at=90] -> Done[total=50]
rejected: Pause[at=5] not allowed in Idle[]
- Each case names one transition, for example
: "running since t0 with b seconds banked, paused at t". The pause at 30 banked 30 seconds, and the stop at 70 added 70 - 50 = 20 for a total of 50.Step(Running(long t0, long b), Pause(long t)) is a local record declared inside the method just to give the pair a type that patterns can deconstruct.Step- The
at 90 arrived afterStart and was ignored by theDone case.Step(Done d, Event e) - The last case,
, catches every combination not listed, such as pausing while idle, and throws. It also makes the switch exhaustive. Here a catch-all is the right design, because invalid transitions are the expected "else" of a state machine.Step(State s, Event e)
null in pattern switches
A switch on an object has always thrown NullPointerExceptionnullcase nullnullcase null, default ->case nullstrictlenient
public class NullHandling {
sealed interface Event permits Login, Logout, Purchase {
}
record Login(String user) implements Event {
}
record Logout(String user) implements Event {
}
record Purchase(String user, int cents) implements Event {
}
// Exhaustive without default; a null selector throws NullPointerException
static String strict(Event e) {
return switch (e) {
case Login(var u) -> u + " logged in";
case Logout(var u) -> u + " logged out";
case Purchase(var u, var c) -> u + " spent " + c + " cents";
};
}
// case null handles the missing event inside the switch
static String lenient(Event e) {
return switch (e) {
case null -> "no event";
case Purchase(var u, var c) when c >= 10_000 -> "large purchase by " + u;
case Purchase p -> "purchase by " + p.user();
case Login l -> "login";
case Logout l -> "logout";
};
}
public static void main(String[] args) {
Event[] events = {new Login("ana"), new Purchase("ana", 25_000),
new Purchase("ben", 499), new Logout("ana"), null};
for (Event e : events) {
String result;
try {
result = strict(e);
} catch (NullPointerException npe) {
result = "NullPointerException";
}
System.out.printf("%-26s | %s%n", result, lenient(e));
}
}
}
Output (left column from strictlenient
ana logged in | login
ana spent 25000 cents | large purchase by ana
ben spent 499 cents | purchase by ben
ana logged out | logout
NullPointerException | no event
- The 25,000-cent purchase hit the guarded case and the 499-cent one the plain
case below it.Purchase p - Only the last row differs in kind:
threwstrict , whileNullPointerException returned "no event".lenient does not count towards exhaustiveness, and acase null label on its own never matchesdefault .null
When the hierarchy changes: default and MatchException
The compiler checks exhaustiveness against the types as they were when the switch was compiled. The program compiles three switches against version 1 of an enum and a sealed interface, then recompiles only those two types with one new case each and calls the old, unrecompiled switches with the new values.
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.tools.ToolProvider;
// Compiles two switches against version 1 of an enum and a sealed interface,
// then recompiles only the types with a new case, and calls the old switches
public class EvolutionCheck {
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("evolve");
Files.writeString(dir.resolve("Level.java"), "public enum Level { LOW, HIGH }");
Files.writeString(dir.resolve("Shape.java"), """
public sealed interface Shape {
record Circle() implements Shape {}
record Square() implements Shape {}
}""");
Files.writeString(dir.resolve("Client.java"), """
public class Client {
public static String exhaustive(Level l) {
return switch (l) { case LOW -> "low"; case HIGH -> "high"; };
}
public static String withDefault(Level l) {
return switch (l) { case LOW -> "low"; default -> "high"; };
}
public static String shape(Shape s) {
return switch (s) {
case Shape.Circle c -> "circle";
case Shape.Square q -> "square";
};
}
}""");
compile(dir, "Level.java", "Shape.java", "Client.java");
// Version 2: one new case each; Client.java is not recompiled
Files.writeString(dir.resolve("Level.java"), "public enum Level { LOW, MEDIUM, HIGH }");
Files.writeString(dir.resolve("Shape.java"), """
public sealed interface Shape {
record Circle() implements Shape {}
record Square() implements Shape {}
record Star() implements Shape {}
}""");
compile(dir, "Level.java", "Shape.java");
try (URLClassLoader loader = new URLClassLoader(new URL[] {dir.toUri().toURL()})) {
Class<?> level = loader.loadClass("Level");
Object medium = level.getField("MEDIUM").get(null);
Object star = loader.loadClass("Shape$Star").getConstructor().newInstance();
Class<?> client = loader.loadClass("Client");
call(client.getMethod("withDefault", level), "withDefault(MEDIUM)", medium);
call(client.getMethod("exhaustive", level), "exhaustive(MEDIUM) ", medium);
call(client.getMethod("shape", loader.loadClass("Shape")), "shape(Star) ", star);
}
}
static void call(Method m, String label, Object arg) throws IllegalAccessException {
try {
System.out.println(label + " returned \"" + m.invoke(null, arg) + "\"");
} catch (InvocationTargetException e) {
System.out.println(label + " threw " + e.getCause());
}
}
static void compile(Path dir, String... files) {
String[] argv = new String[files.length + 4];
argv[0] = "-d";
argv[1] = dir.toString();
argv[2] = "-cp";
argv[3] = dir.toString();
for (int i = 0; i < files.length; i++) {
argv[i + 4] = dir.resolve(files[i]).toString();
}
if (ToolProvider.getSystemJavaCompiler().run(null, null, null, argv) != 0) {
throw new IllegalStateException("javac failed");
}
}
}
Output:
withDefault(MEDIUM) returned "high"
exhaustive(MEDIUM) threw java.lang.MatchException
shape(Star) threw java.lang.MatchException
returned "high". ThewithDefault(MEDIUM) branch swallowed the new constant without any error, at compile time or at run time. This is the trap of addingdefault to switches over sealed types or enums: it disables the very check that sealing provides.default- The exhaustive enum switch and the exhaustive sealed switch both threw
. For a switch with noMatchException , javac adds a hidden one that throws, so a value the code was not compiled for fails loudly.default - Before Java 21, an exhaustive enum switch expression threw
in this situation. JEP 441 changed it toIncompatibleClassChangeError so enums and sealed types behave alike.MatchException - In a normal build, where everything is compiled together, you get a compile error instead and never see this exception. It appears when a library changes under already compiled code.
Design guidelines
| Situation | Approach |
|---|---|
| Fixed set of cases, many operations (eval, show, simplify) | Sealed interface, records and exhaustive pattern switches |
| Open set that third parties extend, few operations | Ordinary interface with methods |
| Cases with no data | An enum, which is also exhaustive in switches |
| Decisions on combinations of two values | Switch over a local record pair, with nested patterns |
| A branch that must stay open for extension | non-sealed |
| Code on Java 17 to 20 | Sealed types with the visitor pattern |
- Avoid
in switches over sealed types and enums unless a catch-all is truly meant, as in the state machine.default - Keep records free of behaviour that belongs to a single operation; put that logic in the operation's switch.
- Put guarded and nested cases before the general case for the same type, or the compiler reports them as dominated.
Related pages: