Why this matters
- Sealed types, records and pattern matching were designed as one feature in three parts. This article is where they combine, and it is the finished form of data-oriented Java.
- Destructuring removes the accessor noise from code that walks a data structure, which is most parsing, interpreting and transforming code.
- Exhaustive switches mean adding a case to your model produces a list of compile errors telling you exactly what to update.
Patterns in switch
// Java 21 — switch over any reference type, with type patterns
String format(Object value) {
return switch (value) {
case Integer i -> "int " + i;
case Long l -> "long " + l;
case String s -> "string of length " + s.length();
case int[] array -> "int array of " + array.length;
case null -> "nothing";
default -> "unknown: " + value.getClass().getSimpleName();
};
}
// With a sealed hierarchy, no default is needed and the switch is exhaustive
sealed interface Shape permits Circle, Square, Triangle { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
record Triangle(double base, double height) implements Shape { }
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
Null in switch
This is a genuine behaviour change worth knowing precisely.
// Traditional switch throws NullPointerException on a null selector
switch (someString) { case "a": break; } // NPE if someString is null
// A switch with patterns can match null explicitly
switch (value) {
case null -> handleMissing();
case String s -> handle(s);
default -> handleOther();
}
Record patterns
A record pattern matches the type and binds the components by position.
record Point(int x, int y) { }
// Type pattern, then manual accessors
if (object instanceof Point point) {
int sum = point.x() + point.y();
}
// Record pattern — destructured in the match
if (object instanceof Point(int x, int y)) {
int sum = x + y; // x and y are bound directly
}
Object oUnknown type
x1Nested component
y1Nested component
p2Whole record
Nested patterns
This is where destructuring earns its place.
record Point(int x, int y) { }
record Line(Point start, Point end) { }
record Path(Line first, Line second) { }
// Without patterns
if (object instanceof Path path) {
Line first = path.first();
Point start = first.start();
int x = start.x();
// ... four lines to reach one value
}
// With a nested record pattern
if (object instanceof Path(Line(Point(int x1, int y1), Point p2), Line second)) {
// x1, y1, p2 and second are all bound, and the whole shape was verified
}
// var infers each component type, which keeps deep patterns readable
if (object instanceof Line(var start, var end)) {
double length = Math.hypot(end.x() - start.x(), end.y() - start.y());
}
Guarded patterns with when
String classify(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "huge circle";
case Circle c -> "circle";
case Square s when s.side() == 0 -> "degenerate square";
case Square s -> "square";
case Triangle t -> "triangle";
};
}
// Guards combine naturally with destructuring
String describe(Object object) {
return switch (object) {
case Point(int x, int y) when x == 0 && y == 0 -> "origin";
case Point(int x, int y) when x == y -> "on the diagonal";
case Point(int x, int y) -> "point at " + x + "," + y;
default -> "not a point";
};
}
A worked example: a small expression evaluator
This is the canonical case for data-oriented programming, and it shows every piece working together.
sealed interface Expr { }
record Num(double value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Mul(Expr left, Expr right) implements Expr { }
record Neg(Expr operand) implements Expr { }
double eval(Expr expr) {
return switch (expr) {
case Num(double value) -> value;
case Add(Expr left, Expr right) -> eval(left) + eval(right);
case Mul(Expr left, Expr right) -> eval(left) * eval(right);
case Neg(Expr operand) -> -eval(operand);
};
}
// Simplification rules read almost like the mathematics
Expr simplify(Expr expr) {
return switch (expr) {
case Mul(Num(double a), Expr ignored) when a == 0 -> new Num(0);
case Mul(Expr e, Num(double b)) when b == 1 -> simplify(e);
case Add(Num(double a), Num(double b)) -> new Num(a + b);
case Neg(Neg(Expr inner)) -> simplify(inner);
default -> expr;
};
}
Design guidance
| Aspect | Data-oriented — sealed + records + switch | Object-oriented — interface + overriding |
|---|---|---|
| Where logic lives | In switches, grouped by operation | In each type, grouped by type |
| Adding an operation | One new method in one place | A new method in every subtype |
| Adding a type | Every switch breaks until handled | The compiler demands the new implementations |
| Best when | The data model is stable | The set of types keeps growing |
| Typical use | Parsers, interpreters, protocol handling, state machines | Plugins, strategies, framework extension points |
Where logic lives
Data-oriented — sealed + records + switchIn switches, grouped by operationObject-oriented — interface + overridingIn each type, grouped by typeAdding an operation
Data-oriented — sealed + records + switchOne new method in one placeObject-oriented — interface + overridingA new method in every subtypeAdding a type
Data-oriented — sealed + records + switchEvery switch breaks until handledObject-oriented — interface + overridingThe compiler demands the new implementationsBest when
Data-oriented — sealed + records + switchThe data model is stableObject-oriented — interface + overridingThe set of types keeps growingTypical use
Data-oriented — sealed + records + switchParsers, interpreters, protocol handling, state machinesObject-oriented — interface + overridingPlugins, strategies, framework extension points
Both compile-time guarantees are real. Choose the one that matches the axis you expect to change.
Unnamed patterns
Java 21 also previewed _ for a binding you do not need, which was finalised in Java 22.
// Preview in 21, standard from 22
switch (shape) {
case Circle(double _) -> "circle"; // the radius is irrelevant here
case Square(double side) -> "square " + side;
case Triangle _ -> "triangle";
}
Common misreadings
- "A pattern switch never throws on null." It throws unless there is an explicit
case null.defaultdoes not match null. - "A record pattern needs the component names to match." Patterns bind by position, not name, so you may use any binding name.
- "Case order is free." A more general pattern before a more specific one is a compile error for being unreachable.
- "
defaultis always allowed." Over a sealed hierarchy with every case covered, the compiler rejects adefaultas unreachable. - "Record patterns match null components." A nested type pattern rejects null; use
varif a component is nullable. - "Pattern matching replaces polymorphism." It is the better choice on a different axis — stable data, growing operations.
- "
whenis a keyword." It is contextual, so existing variables and methods namedwhenstill compile.
Quick recall
Everything you need if you only revisit this box.
switchaccepts type patterns over any reference type, pluscase null— without which null still throws, anddefaultalone does not match null.- Over a sealed hierarchy the switch is exhaustive: no
defaultis needed, anddefaultis rejected as unreachable. - A record pattern destructures by position:
case Point(int x, int y). Patterns nest arbitrarily, andvarinfers each component type. - A nested type pattern rejects null, so the whole pattern fails on a null component.
whenguards add conditions; a more specific case must come before a more general one or it is a compile error.- Sealed + records + switch is data-oriented programming: ideal for parsers, interpreters and state machines where the data model is stable and operations grow.
- Unnamed patterns (
_) were preview in 21, standard in 22.
Test yourself
Answer these before moving on — recall is what makes it stick.