Pattern Matching and Sealed Hierarchies
Java spent twenty years writing this:
public class OldStyle {
sealed interface Shape permits Square, Circle { }
record Square(double side) implements Shape { }
record Circle(double radius) implements Shape { }
static double area(Object value) {
if (value instanceof Square) {
Square square = (Square) value; // test, then cast, then use
return square.side() * square.side();
} else if (value instanceof Circle) {
Circle circle = (Circle) value;
return Math.PI * circle.radius() * circle.radius();
}
throw new IllegalArgumentException("not a shape: " + value);
}
public static void main(String[] args) {
System.out.println(area(new Square(2)));
System.out.println(area(new Circle(1)));
}
}
The test and the cast name the same type twice, nothing checks that every case is covered, and the failure for a new shape arrives at runtime.
instanceof with a pattern
public class InstanceOf {
public static void main(String[] args) {
for (Object value : new Object[]{"hello", 42, 3.5, java.util.List.of(1, 2)}) {
System.out.println(describe(value));
}
}
static String describe(Object value) {
// The variable is declared by the test, and only exists where the test
// passed. No cast, and no way to use it in the wrong branch.
if (value instanceof String s && s.length() > 3) {
return "a long string: " + s.toUpperCase();
}
if (value instanceof String s) {
return "a short string: " + s;
}
if (value instanceof Integer i) {
return "an int, doubled: " + (i * 2);
}
return "something else: " + value;
}
}
switch over a sealed hierarchy, checked for exhaustiveness
public class Sealed {
sealed interface Shape permits Square, Circle, Rectangle { }
record Square(double side) implements Shape { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
// No default branch. The compiler knows the permitted subtypes, so it can
// tell that every one is handled - and will refuse to compile this method
// the day a fourth Shape is added.
static double area(Shape shape) {
return switch (shape) {
case Square s -> s.side() * s.side();
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
public static void main(String[] args) {
for (Shape shape : new Shape[]{new Square(2), new Circle(1), new Rectangle(2, 3)}) {
System.out.printf("%-28s %.3f%n", shape, area(shape));
}
}
}
That is the trade the whole feature exists for: a little structure up front, in exchange for the compiler carrying the list of cases for you.
Record patterns take the object apart
public class RecordPatterns {
sealed interface Shape permits Square, Circle { }
record Point(double x, double y) { }
record Square(Point origin, double side) implements Shape { }
record Circle(Point centre, double radius) implements Shape { }
static String describe(Shape shape) {
return switch (shape) {
// Destructure in the pattern: the components are bound directly,
// including the nested Point.
case Square(Point(var x, var y), var side) when side > 10 ->
"a big square at " + x + "," + y;
case Square(Point p, var side) ->
"a square of " + side + " at " + p;
case Circle(Point(var x, var y), var r) ->
"a circle of radius " + r + " at " + x + "," + y;
};
}
public static void main(String[] args) {
System.out.println(describe(new Square(new Point(0, 0), 2)));
System.out.println(describe(new Square(new Point(1, 1), 20)));
System.out.println(describe(new Circle(new Point(3, 4), 5)));
}
}
when adds a condition to a pattern. Order matters: the first matching case
wins, so the guarded one has to come before the general one - and the compiler
will tell you if a later case could never be reached.
Modelling a result, so the caller cannot ignore the failure
This is where the Level 2 lesson on exceptions meets this one. An expected outcome is data; a sealed result type makes the compiler insist it is handled.
public class Results {
sealed interface Result<T> permits Success, Failure, Pending { }
record Success<T>(T value) implements Result<T> { }
record Failure<T>(String message) implements Result<T> { }
record Pending<T>() implements Result<T> { }
static String render(Result<Integer> result) {
return switch (result) {
case Success<Integer> s -> "value " + s.value();
case Failure<Integer> f -> "failed: " + f.message();
case Pending<Integer> ignored -> "still working";
};
}
public static void main(String[] args) {
java.util.List<Result<Integer>> results = java.util.List.of(
new Success<>(42), new Failure<>("timeout"), new Pending<>());
results.stream().map(Results::render).forEach(System.out::println);
}
}
Delete the Pending case and the code stops compiling, naming the type you
missed. A thrown exception offers no such guarantee: nothing makes a caller
catch it, and nothing tells you when a new failure mode appears.
null in a switch
Historically switch (null) threw a NullPointerException. A pattern switch
can name null as a case, which is both clearer and safer than a check before
the statement.
public class Nulls {
public static void main(String[] args) {
for (Object value : new Object[]{"text", 42, null}) {
System.out.println(describe(value));
}
}
static String describe(Object value) {
return switch (value) {
case null -> "nothing at all";
case String s -> "a string: " + s;
case Integer i -> "an int: " + i;
default -> "something else";
};
}
}
What to take away
instanceof Type tdeclares the variable and scopes it to where the test passed.- A
switchover a sealed type needs no default, and is checked for exhaustiveness. - Adding a subtype then breaks compilation everywhere it should - by design.
- Record patterns destructure, including nested records, and
whenguards a case. - A sealed result type makes the caller handle every outcome the compiler knows about.
- A pattern switch can handle
nullas a case instead of throwing.