Pattern Matching for switch
Pattern matching for switch (Java 21+) extends switch beyond simple constants to match on an object's type and shape, with guards, null handling, and compiler-checked exhaustiveness.
Pattern matching for switch, finalized in Java 21 (JEP 441, after several preview rounds since Java 17), lets a switch statement or expression branch on the *type* (and, with record patterns, the *shape*) of a value, not just equality against constants. Combined with sealed hierarchies, the compiler can even verify that every case is covered.
From Constant Switch to Type Patterns
Traditional switch only matches exact constant values (int, String, enum). A type pattern in a case label instead matches based on the runtime type of the switch's selector expression, binding a pattern variable just like pattern matching for instanceof.
static String describe(Object obj) {
return switch (obj) {
case Integer i -> "int: " + i;
case String s -> "string: " + s;
case null -> "it's null";
default -> "something else: " + obj;
};
}
System.out.println(describe(42)); // int: 42
System.out.println(describe("hi")); // string: hi
System.out.println(describe(null)); // it's nullGuarded Patterns (when clauses)
A case label can add a boolean condition with when, refining a type pattern match further — the case only applies if the type matches AND the when condition is true. This replaces nested if-statements inside a matched case.
Case labels are checked top-to-bottom; the first matching pattern (type AND guard) wins, so order matters when patterns overlap.
static String classify(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "negative int";
case Integer i when i == 0 -> "zero";
case Integer i -> "positive int";
default -> "not an int";
};
}
System.out.println(classify(-5)); // negative int
System.out.println(classify(0)); // zero
System.out.println(classify(7)); // positive intExplicit null Handling
Traditionally, switch throws NullPointerException immediately if the selector is null — a frequent source of bugs. Pattern-matching switch lets you add an explicit case null -> label to handle it deliberately, in the same switch, instead of needing a separate null-check before the switch.
If a switch has no case null label, the traditional behavior is preserved: a null selector still throws NullPointerException, matching pre-21 switch behavior for compatibility.
static String safeDescribe(Object obj) {
return switch (obj) {
case null -> "was null";
case String s -> "string of length " + s.length();
default -> "other";
};
}
System.out.println(safeDescribe(null)); // was null (no NullPointerException)Record Patterns (Deconstruction)
Record patterns (also finalized in Java 21, JEP 440) let a case label destructure a record's components directly, binding each component to its own pattern variable in one step — useful when switching on the shape of structured data.
record Point(int x, int y) {}
static String locate(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == 0 && y == 0 -> "origin";
case Point(int x, int y) -> "point at " + x + "," + y;
default -> "not a point";
};
}
System.out.println(locate(new Point(0, 0))); // origin
System.out.println(locate(new Point(3, 4))); // point at 3,4Exhaustiveness With Sealed Hierarchies
For a switch expression (which must always produce a value on every path), the compiler requires exhaustiveness — every possible input must be covered, or a default branch must be present. When switching over a sealed interface or class, the compiler knows the complete, closed set of permitted subtypes and can verify exhaustiveness WITHOUT requiring a default case at all, since it can see every possibility.
If you later add a new subtype to the permits clause without updating every switch over that sealed type, the compiler flags the now-non-exhaustive switch as a compile-time error — a powerful safety net when evolving a closed hierarchy.
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 {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square sq -> sq.side() * sq.side();
case Triangle t -> 0.5 * t.base() * t.height();
// no default needed - compiler proves all permitted subtypes are covered
};
}Non-Sealed Types Still Need default
For an ordinary (non-sealed) reference type, the set of possible subtypes is open-ended — any code anywhere could define a new subtype. So the compiler cannot prove exhaustiveness without a default (or a final catch-all type pattern for the switch's declared type), and omitting one is a compile-time error for a switch expression.
static String describeShape(Object obj) {
return switch (obj) {
case Integer i -> "int";
case String s -> "string";
// compile-time error without a default: 'obj' is Object, an open type
default -> "other";
};
}Case Label Ordering: Most Specific First
When case patterns overlap (e.g. a supertype pattern and a subtype pattern), Java requires the more specific (subtype) pattern to appear before the more general one — otherwise the general case would always win and the specific one could never be reached, which the compiler flags as an error.
// compile-time error: 'this case label is dominated by a preceding case label'
// switch (obj) {
// case Object o -> "any object";
// case String s -> "string"; // unreachable - Object already matched everything
// }
switch (obj) {
case String s -> System.out.println("string"); // specific first
case Object o -> System.out.println("any object"); // general last, acts like default
}Fall-Through Differences From Traditional switch
Arrow-form (->) case labels used with pattern matching do NOT fall through to the next case — each matched case executes only its own body, just like arrow-form switch in general (see the Switch Expressions topic). Traditional colon-form (:) labels can technically be mixed with type patterns in a switch statement, but pattern variables and fall-through interact awkwardly, so arrow form is strongly preferred for pattern-matching switches.
Mixing type patterns with old-style fall-through colon labels is legal but discouraged — it reintroduces the classic missing-break bug risk into code that's supposed to be safer, and is rarely seen in modern code.
Java Version
Pattern matching for switch (type patterns, guarded patterns, null handling) and record patterns were both previewed across Java 17-20 and became standard, permanent language features together in Java 21 (JEP 441 and JEP 440). Java 21+ needs no preview flags to use them.
Key Takeaways
Type patterns in case labels let switch branch on runtime type, not just constant equality.
when guards refine a type pattern with an additional boolean condition.
An explicit case null -> handles null deliberately, avoiding the traditional NullPointerException on a null selector.
Switching over a sealed hierarchy lets the compiler verify exhaustiveness without a default case.
More specific (subtype) case patterns must precede more general ones, or the compiler rejects the unreachable case.
switch (obj) { case Object o -> System.out.println("any"); case String s -> System.out.println("string"); // unreachable - compile error } switch (obj) { case String s -> System.out.println("string"); case Object o -> System.out.println("any"); }
More specific type patterns must come before more general ones, or the general pattern dominates and makes the specific case unreachable, which the compiler rejects.
String result = switch (obj) { case Integer i -> "int"; case String s -> "string"; }; // compile error: not exhaustive, obj is Object
String result = switch (obj) { case Integer i -> "int"; case String s -> "string"; default -> "other"; };
Object is an open (non-sealed) type, so the compiler cannot prove every possible subtype is covered — a switch expression must be exhaustive, so a default (or catch-all) is required.
// Assuming a switch without case null still throws NPE is 'the old unsafe behavior to avoid' switch (obj) { case String s ->...; default ->...; // obj == null still throws NullPointerException here! } switch (obj) { case null ->...; // handle explicitly case String s ->...; default ->...; }
Without an explicit case null, a switch still throws NullPointerException on a null selector for backward compatibility — add case null deliberately if null is a valid input you need to handle.
How does a type pattern in a switch case differ from a traditional constant case label?
A traditional case label matches by equality against a fixed constant (an int, String literal, or enum constant). A type pattern instead matches based on the runtime type of the selector expression, and binds a new pattern variable of that type for use in the case body.
What does a when clause add to a case pattern, and how is it different from having separate switch cases?
A when clause adds a boolean guard condition that must also be true for that case to match, on top of the type pattern matching. It lets you split one type into multiple, differently-handled sub-cases (e.g. Integer i when i < 0) within the same switch, evaluated top-to-bottom.
What happens if a switch's selector is null and there is no case null label?
It throws NullPointerException immediately, preserving traditional pre-Java-21 switch behavior for backward compatibility, even though the switch otherwise uses pattern matching.
Why can the compiler verify exhaustiveness of a switch over a sealed interface without requiring a default case?
A sealed interface declares its complete, closed set of permitted direct subtypes via its permits clause. The compiler can see that entire set at compile time and confirm every one of them is handled by some case, so it doesn't need a fallback default to guarantee every possible input is covered.
Why does a switch over a plain (non-sealed) type still require a default case?
A non-sealed type's set of subtypes is open — any code, anywhere, could define a new subtype at any time — so the compiler has no way to enumerate 'every possibility' and can't prove exhaustiveness without an explicit catch-all default.
Why must a more specific case pattern be listed before a more general one?
Case labels are evaluated in order, and the first matching pattern wins. If a general pattern (like a supertype) came first, it would always match before the compiler ever reached the more specific subtype case, making that later case dead code — so the compiler rejects that ordering as an error.
What are record patterns, and what problem do they solve when used inside a switch?
Record patterns let a case label destructure a record's components directly (e.g. case Point(int x, int y)), binding each component to its own variable in one step, instead of matching the record's type and then separately calling accessor methods to pull out each field.
Write a switch expression over Object that returns "empty string", "non-empty string", or "other" for a String and any other type, using a guarded pattern.
static String classify(Object obj) { return switch (obj) { case String s when s.isEmpty() -> "empty string"; case String s -> "non-empty string"; default -> "other"; }; } System.out.println(classify("")); // empty string System.out.println(classify("hi")); // non-empty string System.out.println(classify(42)); // other empty string, non-empty string, other — the guarded pattern (s.isEmpty()) is checked first for Strings, falling through to the plain String pattern, then to default for non-Strings.
Does this switch expression compile without a default case? Why or why not?
Yes, it compiles. Animal is sealed and permits exactly Dog and Cat; the switch covers both, so the compiler can prove exhaustiveness without needing a default branch.
Write a switch that handles case null explicitly with the message "no input", and a String case with its length, using pattern matching.
static String describe(Object obj) { return switch (obj) { case null -> "no input"; case String s -> "length " + s.length(); default -> "unhandled"; }; } System.out.println(describe(null)); // no input System.out.println(describe("abc")); // length 3 no input, then length 3 — the explicit case null branch handles a null selector deliberately instead of throwing NullPointerException.
What does this print?
on y-axis — the record pattern Point(int x, int y) destructures the point, and the guard x == 0 matches since the Point's x component is 0.
sealed interface Animal permits Dog, Cat {}
record Dog(String name) implements Animal {}
record Cat(String name) implements Animal {}
static String sound(Animal a) {
return switch (a) {
case Dog d -> d.name() + " says Woof";
case Cat c -> c.name() + " says Meow";
};
}record Point(int x, int y) {}
Object o = new Point(0, 5);
String result = switch (o) {
case Point(int x, int y) when x == 0 -> "on y-axis";
case Point p -> "elsewhere";
default -> "not a point";
};
System.out.println(result);