Java's switch spent two decades limited to primitives, enums, and strings, with fall-through as the default and a break you had to remember on every branch. Pattern matching for switch, finalized in JDK 21, is close to a different language feature wearing the same keyword — it can match on type, destructure records, and guard with arbitrary conditions, all while the compiler checks you've covered every case.
From instanceof Chains to Type Patterns
The pre-pattern-matching way of branching on type looked like this:
static double perimeter(Object shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return 2 * Math.PI * c.radius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return 2 * (r.width() + r.height());
} else {
throw new IllegalArgumentException("Unknown shape");
}
}Every branch repeats the same three steps: check the type, cast, extract fields. Pattern matching for switch collapses all of it:
static double perimeter(Object shape) {
return switch (shape) {
case Circle c -> 2 * Math.PI * c.radius();
case Rectangle r -> 2 * (r.width() + r.height());
default -> throw new IllegalArgumentException("Unknown shape");
};
}No explicit cast is needed — c and r are already typed correctly inside their branches, and the compiler enforces it. Combined with a sealed hierarchy for shape's type, the default branch can be dropped entirely, and the compiler will verify exhaustiveness for you at compile time.
Record Patterns: Destructuring, Not Just Matching
Record patterns, also from JDK 21, let a case reach directly into a record's components instead of matching the whole record and then calling accessors separately.
sealed interface Shipment permits Domestic, International {}
record Domestic(String city, String zip) implements Shipment {}
record International(String country, String customsCode) implements Shipment {}
static String label(Shipment shipment) {
return switch (shipment) {
case Domestic(String city, String zip) -> city + ", " + zip;
case International(String country, String code) when code.isBlank() ->
"International: " + country + " (customs pending)";
case International(String country, String code) ->
country + " [" + code + "]";
};
}Patterns can nest arbitrarily deep — a record containing another record can be destructured in one case line — which is especially useful when matching over parsed data or event payloads with several levels of structure.
Guards With when
The when clause attaches an arbitrary boolean condition to a case, evaluated only after the pattern itself matches. This replaces what used to require a nested if inside the branch body, and it participates in exhaustiveness checking — the compiler knows a guarded pattern might not match, so it won't let a guarded case stand in for full coverage of that type on its own.
static String classify(Object value) {
return switch (value) {
case Integer i when i < 0 -> "negative int";
case Integer i when i == 0 -> "zero";
case Integer i -> "positive int";
case String s when s.isEmpty() -> "empty string";
case String s -> "string: " + s;
default -> "other";
};
}null Handling Is Explicit Now
Traditional switch throws NullPointerException if the selector is null. Pattern-matching switch lets you handle it as an explicit case instead of forcing a null check beforehand:
static String describe(Object value) {
return switch (value) {
case null -> "nothing here";
case Integer i -> "int: " + i;
default -> "something else";
};
}| Old style | New style |
|---|---|
instanceof + manual cast |
Type pattern, no cast needed |
Nested if inside branch |
when guard on the case |
| Manual accessor calls after cast | Record pattern destructuring |
| Null-check before switch | case null -> |
The net effect across all of this is that a switch over a sealed type now reads as a complete, checked specification of every shape your data can take — closer to how pattern matching works in languages built around it from the start, without leaving Java's syntax behind.