On this page
Description
A lot of code has to handle several kinds of something: a payment result that's approved, declined or under review; a shape that's a circle or a rectangle; a message that's a text, an image or a file. Before Java 21, that code usually looked like this:
double area(Shape s) {
if (s instanceof Circle c) return Math.PI * c.r() * c.r();
else if (s instanceof Rect r) return (r.x2() - r.x1()) * (r.y2() - r.y1());
else throw new IllegalArgumentException("unknown shape " + s);
}
It works until someone adds a Triangle: the code still compiles, and fails at run time with that exception. Java 21's pattern matching for switch and record patterns, together with the sealed types from Java 17, turn that run-time failure into a compile error. The compiler checks that every kind is handled, and you can pull a record apart in the same line that matches it.
Here's the path, from easiest to most technical:
- Pattern matching in a
switch: matching on types. - The four rules: order,
null, guards, and covering every case. - Record patterns: matching and unpacking in one step.
- Putting it together: a closed set of cases, handled exhaustively.
- When to use this style, and when a method on each class is better.
Pattern matching in a switch
A switch can now match on types, binding a variable of that type when it matches:
String describe(Object o) {
return switch (o) {
case null -> "nothing";
case Integer i when i > 100 -> "big number";
case Integer i -> "number " + i;
case String s -> "text of " + s.length() + " characters";
default -> "something else";
};
}
// describe(null) -> nothing
// describe(250) -> big number
// describe(7) -> number 7
// describe("Austin") -> text of 6 characters
// describe(2.5) -> something else
case Integer i matches when o is an Integer and names it i. A guard (when i > 100) adds a condition.
The four rules
1. The first matching case wins, so put specific cases first. If an earlier case already catches everything a later one would, the later one can never run, and Java refuses to compile it: case CharSequence cs before case String s is an error ("this case label is dominated by a preceding case label"). Guarded and more specific cases go above general ones.
2. null needs its own case. Without case null, a null value makes the switch throw a NullPointerException, as switches always have. With case null ->, it's just another case.
3. Guards don't count toward covering everything. The compiler can't know whether a guard is ever true, so a guarded case only covers some values of its type. If you have case Circle c when c.r() > 10, you also need a plain case Circle c (or a default) for the small circles.
4. A pattern switch must cover every possible value. You can cover everything with a default, or, for a sealed type, by listing every permitted subtype. This applies to pattern switch statements too, not only expressions.
That last rule is where the safety comes from. Over a sealed type, leave default out and list every subtype. Then adding a new subtype turns every switch that doesn't handle it into a compile error, a to-do list from the compiler. A default would quietly absorb the new kind.
Record patterns
A record pattern matches a record and unpacks its components at the same time:
record Point(int x, int y) {}
record Rect(Point topLeft, Point bottomRight) implements Shape {}
record Circle(Point center, int r) implements Shape {}
int width(Shape s) {
return switch (s) {
case Circle(Point center, int r) -> 2 * r;
case Rect(Point(var x1, var y1), Point(var x2, var y2)) -> x2 - x1; // patterns nest
};
}
if (o instanceof Point(int x, int y)) { ... } // works with instanceof too
- Components are matched by position, with their types or
var. - Patterns nest, so a
Rectmade of twoPoints unpacks all the way down in one case. - A nested pattern like
Point(var x1, var y1)doesn't match anullcomponent, so aRectwhose first point isnullfalls through to the next case. - The unnamed pattern
_, for components you don't need (case Circle(_, int r)), was a preview in Java 21 and became final in Java 22.
Putting it together
Sealed interfaces, records and pattern switches combine into a clean way to model "one of several kinds, each with its own data":
sealed interface PaymentResult permits Approved, Declined, NeedsReview {}
record Approved(String authCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record NeedsReview(int riskScore) implements PaymentResult {}
String message(PaymentResult r) {
return switch (r) {
case Approved(var code) -> "Paid (" + code + ")";
case Declined(var reason) -> "Declined: " + reason;
case NeedsReview(var score) when score >= 80 -> "Blocked for review";
case NeedsReview n -> "Pending review";
}; // no default: adding a fourth result type won't compile until it's handled
}
This style is often called data-oriented programming: model the data as a closed set of records, and write each operation as one switch over them.
When to use this style
It's not a replacement for ordinary polymorphism. The question is which side you expect to grow:
| You expect to add more... | Prefer |
|---|---|
| operations (render, store, audit, price...) over a stable set of kinds | a sealed type and pattern switches: one new switch per operation |
| kinds (new subclasses) with a stable set of operations | a method on each class: one new class per kind |
Interviewers like this question, and the answer is "it depends on which axis grows".
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| A general case above a specific one | compile error: dominated | specific and guarded cases first |
No case null where null can occur | NullPointerException | add case null -> |
| Only guarded cases for a type | not exhaustive: doesn't compile | add an unguarded case for that type |
default in a switch over your own sealed type | new kinds slip through silently | list every subtype, no default |
A nested pattern and a null component | that case doesn't match | add a fallback case for the record |
_ on Java 21 without preview flags | doesn't compile | name the variable, or use Java 22+ |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Why doesn't case CharSequence cs -> ...; case String s -> ...; compile?
Show answer
Every String is a CharSequence, so the second case could never match: it's dominated by the first. Put the more specific case first.
2. A switch over a sealed Shape has case Circle c when c.r() > 10 and case Square q. Why doesn't it compile?
Show answer
Guarded cases don't count toward covering every value: a Circle with a small radius would match nothing. Add a plain case Circle c.
3. What happens when a pattern switch with no case null gets a null?
Show answer
It throws a NullPointerException. Add case null -> to handle it.
4. Why leave out default in a switch over your own sealed interface?
Show answer
So the compiler checks that every permitted subtype is handled. When someone adds a new subtype, every switch that doesn't handle it stops compiling, instead of a default quietly handling it the wrong way.
Key takeaways
- A pattern switch matches on types, binds variables, and can add
whenguards; the first match wins. - Specific cases first;
case nullifnullcan occur; guarded cases don't cover their type. - Over a sealed type, list every subtype and skip
default, so new kinds become compile errors. - Record patterns match and unpack in one step, and they nest.
- Sealed types plus records plus pattern switches suit a stable set of kinds with growing operations.