Java Advanced: Generics, Streams and Concurrency
Modern Java: Sealed Types and Pattern Matching
In this lesson you will
- Use pattern matching with instanceof and switch
- Model alternatives with sealed interfaces and records
- Destructure records with record patterns
Introduction
Java has changed a great deal since version 8. Recent releases brought features that make code shorter and the compiler smarter. This lesson covers the ones that work together: pattern matching, sealed types and record patterns. All examples need Java 21 or newer, which is a long-term-support release.
Pattern matching for instanceof
The old way to check a type and use it required a separate cast:
Java
public class Main {
static String describe(Object o) {
if (o instanceof String) {
String s = (String) o;
return "text of length " + s.length();
}
return "something else";
}
public static void main(String[] args) {
System.out.println(describe("hello"));
System.out.println(describe(42));
}
}text of length 5
something elseWith a pattern, the check and the variable are combined, and no cast is needed:
Java
public class Main {
static String describe(Object o) {
if (o instanceof String s && s.length() > 3) {
return "long text: " + s;
}
if (o instanceof Integer i) {
return "number " + (i + 1);
}
return "something else";
}
public static void main(String[] args) {
System.out.println(describe("hello"));
System.out.println(describe("hi"));
System.out.println(describe(41));
}
}long text: hello
something else
number 42o instanceof String s tests the type and, if true, gives you s as a String. You can even continue the condition with && s.length() > 3.
Quick quiz
What does o instanceof String s do?
Pattern matching for switch
switch can match on types too, which replaces long if-else chains of instanceof.
Java
public class Main {
static String format(Object o) {
return switch (o) {
case null -> "nothing";
case Integer i when i > 100 -> "big number " + i;
case Integer i -> "number " + i;
case String s -> "text \"" + s + "\"";
case int[] arr -> "array of " + arr.length;
default -> "unknown: " + o;
};
}
public static void main(String[] args) {
System.out.println(format(5));
System.out.println(format(500));
System.out.println(format("hi"));
System.out.println(format(new int[] {1, 2, 3}));
System.out.println(format(null));
System.out.println(format(2.5));
}
}number 5
big number 500
text "hi"
array of 3
nothing
unknown: 2.5The when clause adds an extra condition (a guard). Cases are checked from the top, so put the more specific Integer i when i > 100 before the general Integer i. The compiler complains if an earlier case makes a later one unreachable.
Sealed types
Sometimes you know all the possible kinds of something: a shape is a circle, a square or a rectangle, and nothing else. A sealed interface (Java 17+) lists exactly which types may implement it.
Java
sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
public class Main {
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Rectangle r -> r.width() * r.height();
};
}
public static void main(String[] args) {
System.out.println(Math.round(area(new Circle(1)) * 100) / 100.0);
System.out.println(area(new Square(3)));
System.out.println(area(new Rectangle(2, 5)));
}
}3.14
9.0
10.0Notice that the switch has no default. Because Shape is sealed, the compiler knows the three cases cover everything. If you later add Triangle to the permits list, every switch that forgot it stops compiling, which is exactly the safety net you want.
Record patterns
A record pattern takes a record apart in the pattern itself, giving direct access to its components.
Java
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
public class Main {
static String describe(Shape shape) {
return switch (shape) {
case Circle(double r) -> "circle of radius " + r;
case Rectangle(double w, double h) when w == h -> "square of side " + w;
case Rectangle(double w, double h) -> "rectangle " + w + " by " + h;
};
}
public static void main(String[] args) {
System.out.println(describe(new Circle(2)));
System.out.println(describe(new Rectangle(3, 3)));
System.out.println(describe(new Rectangle(2, 5)));
}
}circle of radius 2.0
square of side 3.0
rectangle 2.0 by 5.0case Rectangle(double w, double h) checks that the shape is a Rectangle and extracts its width and height into w and h in one step. Patterns can nest, so you can take apart records inside records.
Quick quiz
Why does a switch over a sealed interface not need a default case?
Modelling data: an expression evaluator
Putting it all together, a small calculator whose expressions are a sealed hierarchy of records. This style is sometimes called an algebraic data type.
Java
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr inner) implements Expr {}
public class Main {
static int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
case Neg(Expr inner) -> -eval(inner);
};
}
static String show(Expr e) {
return switch (e) {
case Num n -> String.valueOf(n.value());
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);
};
}
public static void main(String[] args) {
Expr e = new Mul(new Add(new Num(2), new Num(3)), new Neg(new Num(4)));
System.out.println(show(e));
System.out.println(eval(e));
}
}(2 + 3) * -4
-20Each operation is one tidy switch, and adding a new kind of expression makes the compiler point at every switch that needs updating.
var and other small improvements
A few other modern features you will meet:
varfor local variables:var names = new ArrayList<String>();- Text blocks
"""for multi-line strings (Java 15). list.stream().toList()for an unmodifiable list (Java 16).- Enhanced
switchexpressions with arrows (Java 14).
Try it yourself
On your computer (Java 21), create a sealed interface Vehicle with records Car(int seats), Bike() and Truck(double loadTons). Write a method static String describe(Vehicle v) using a switch with record patterns, and a toll(Vehicle v) method that returns 2.0 for a bike, 5.0 for a car, and 10.0 plus 2.0 per ton for a truck. Try removing one case and read the compiler error.
Summary
instanceofpatterns (o instanceof String s) combine the type check and the variable.switchcan match types, withwhenguards, and anullcase.- A sealed interface lists all permitted subtypes, so a switch over it needs no
defaultand is checked for completeness. - Record patterns (
case Circle(double r)) take records apart, and patterns can be nested.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.
Sources and further reading