Appearance
Java 21: Pattern Matching for Switch
The Problem with Traditional Switch
Before Java 21, you knew switch as a tool for making decisions based on simple values. You could plug in an int, a String, a char, an enum, or any of their primitive wrapper types. The code read clearly and ran fast. But the moment you had an Object in your hands and wanted to branch based on what type it was, switch would give you nothing. You were on your own with chains of if/else if blocks, each one doing instanceof checks and manual casts. It worked, but it was noisy, repetitive, and easy to get wrong.
Java 21 fixed this. Pattern matching for switch lets you write a switch on any object, automatically checking its type in each case, automatically doing the cast for you, and even letting you add extra conditions right inside the case label. This is the complete guide to how it works, why it was built this way, and what you need to know for technical interviews.
What You Already Know About Switch
Let's ground everything in what came before. The classic switch statement supported these types:
- Primitive types:
int,short,byte,char - Their wrapper classes:
Integer,Short,Byte,Character enumtypesString(added in Java 7)
You could write it as a statement with break:
java
int day = 2;
switch (day) {
case 1:
System.out.println("Monday");
break;
case 2:
System.out.println("Tuesday");
break;
default:
System.out.println("Other");
}Or as an expression with arrow labels (added in Java 14):
java
String result = switch (day) {
case 1 -> "Monday";
case 2 -> "Tuesday";
default -> "Other";
};
System.out.println(result);Java 21 pattern matching for switch keeps everything from both of those worlds. All the classic types still work. Arrow syntax still works. yield inside blocks still works. Nothing was taken away. What was added is the ability to match on objects by type.
Type Patterns in Switch: Matching by Type
Here is the core new ability. You now write case Type variableName as a case label, and Java checks whether the switch value is an instance of that type. If the check passes, the value is automatically cast and bound to the variable name you gave it.
java
Object obj = "Hello World";
switch (obj) {
case String s -> System.out.println("String of length: " + s.length());
case Integer i -> System.out.println("Integer value: " + i);
case Double d -> System.out.println("Double value: " + d);
default -> System.out.println("Something else");
}Read that case String s label out loud: "if obj is an instance of String, cast it to String and call it s." That is exactly what happens. You can use s inside that case branch freely, with full type safety, no manual casting required.
In meaning, it is equivalent to this chain:
java
if (obj instanceof String s) {
System.out.println("String of length: " + s.length());
} else if (obj instanceof Integer i) {
System.out.println("Integer value: " + i);
} else if (obj instanceof Double d) {
System.out.println("Double value: " + d);
} else {
System.out.println("Something else");
}So think of each type case as an instanceof check, tried top to bottom. The switch syntax is just a cleaner, more structured way to express them.
An Important Performance Note
A classic switch on int, char, String or enum constants can be compiled into a jump table or lookup table, which is why it is often faster than a long if/else if chain. A switch over object types cannot use such a table: cases are type tests checked in order (javac compiles it to an invokedynamic call to SwitchBootstraps.typeSwitch). So choose pattern switch for readability and compiler checks, not for speed over if/else if.
Scope of Pattern Variables
The variable you declare in a case label belongs to that case block only. It is not visible in other case blocks, in the default block, or anywhere after the switch statement ends.
java
Object obj = "hello";
switch (obj) {
case String s -> {
System.out.println(s.toUpperCase()); // s is visible here
}
case Integer i -> {
// s is NOT visible here
System.out.println(i + 10);
}
default -> {
// s and i are NOT visible here
System.out.println("unknown");
}
}
// s and i are NOT visible here eitherThis makes sense when you think about it. Each pattern variable only has a well defined type and value if that specific case matched. Letting it leak out would mean carrying around a variable that might not have been initialized, which Java rightly forbids.
Pattern Matching with Inheritance
This is where things get interesting and where you need to think carefully about what types are valid case labels.
Imagine this class hierarchy:
java
abstract class Vehicle {
abstract void drive();
}
class TwoWheeler extends Vehicle {
@Override
public void drive() { System.out.println("Riding two wheels"); }
}
class Bike extends TwoWheeler {
@Override
public void drive() { System.out.println("Riding bike"); }
}
class Cycle extends TwoWheeler {
@Override
public void drive() { System.out.println("Riding cycle"); }
}
class FourWheeler extends Vehicle {
@Override
public void drive() { System.out.println("Driving four wheels"); }
}Now suppose you have a method that receives a TwoWheeler and needs to handle it:
java
void validate(TwoWheeler twObj) {
switch (twObj) {
case Bike b -> System.out.println("It's a bike: " + b);
case Cycle c -> System.out.println("It's a cycle: " + c);
case TwoWheeler tw -> System.out.println("Generic two wheeler: " + tw);
// no default needed: case TwoWheeler matches everything left
}
}The rule is simple: a type is only a valid case label if the switch value could possibly be an instance of that type. Since your switch value is a TwoWheeler, these are valid:
Bikeis valid because aTwoWheelerreference can point to aBikeobjectCycleis valid for the same reasonTwoWheeleritself is valid because the object could be aTwoWheelerdirectlyVehicleis valid because aTwoWheeleris always aVehicle
This would be invalid:
java
case FourWheeler fw -> ... // Compile error! A TwoWheeler can NEVER be a FourWheelerThe compiler knows the type hierarchy and rejects cases that can never match.
The Duplicate Unconditional Pattern Error
Here is a specific compile error you will see when you make a logical mistake. Look at this:
java
void validate(TwoWheeler twObj) {
switch (twObj) {
case Bike b -> System.out.println("bike");
case Cycle c -> System.out.println("cycle");
case TwoWheeler tw -> System.out.println("two wheeler"); // catches everything remaining
case Vehicle v -> System.out.println("vehicle"); // ERROR!
}
}This produces: "Duplicate unconditional pattern"
Why? Because case TwoWheeler tw is an unconditional pattern for the switch value type. Any TwoWheeler object, including Bike and Cycle, can reach the TwoWheeler case if none of the earlier ones matched. After TwoWheeler is already there to catch everything, adding Vehicle is redundant. Both would cover every remaining scenario, so Java flags this as a duplicate.
The fix: use one or the other. You do not need both TwoWheeler and Vehicle when your switch value is already a TwoWheeler.
Grouping Patterns Is Not Allowed
In a traditional switch, you can stack multiple labels before a single body:
java
// Traditional grouping (fine for simple values)
switch (day) {
case 1:
case 7:
System.out.println("Weekend");
break;
}You might want to do the same with type patterns:
java
// THIS DOES NOT COMPILE
switch (obj) {
case Circle c:
case Square s:
System.out.println("Shape"); // which variable do you use here?
}Java rejects this (on JDK 21+ javac reports "illegal fall-through to a pattern").
Think about why. If you write case Circle c: case Square s: together, and both matched somehow, which variable would you use in the body? Is it c or is it s? There is no way to guarantee exactly one of them is initialized. So Java says: if any case label in a group declares a pattern variable, that label must stand alone.
You also cannot write case Circle c, Square s. That syntax is not valid for pattern cases. Each type pattern gets its own dedicated arrow or colon block.
Pattern Matching with Enums
Pattern matching works with enums too. Before Java 21, matching an enum in a switch looked like this:
java
enum Color { RED, GREEN, BLUE, YELLOW }
Object obj = Color.RED;
// Traditional approach
switch ((Color) obj) { // manual cast required!
case RED:
System.out.println("Red");
break;
case GREEN:
System.out.println("Green");
break;
default:
System.out.println("Other");
}With pattern matching for switch in Java 21:
java
switch (obj) {
case Color c -> System.out.println(c.name()); // prints RED, GREEN, etc.
default -> System.out.println("Not a color");
}The object is checked as an instance of Color. If it passes, it is automatically cast to Color and bound to c. You can then call c.name() or c.ordinal() or any other Color method directly. No manual cast, no noise.
Null Handling in Pattern Switch
A null value never matches a type pattern (just like null instanceof String is false), so no cast of null ever happens. But be careful: default does not catch null. If the value is null and there is no case null, the switch throws NullPointerException:
java
Object obj = null;
switch (obj) {
case String s -> System.out.println("String: " + s);
default -> System.out.println("Other"); // NOT reached for null
}
// throws NullPointerExceptionTo handle null, add a case null label (Java 21):
java
switch (obj) {
case null -> System.out.println("Got null!");
case String s -> System.out.println("String: " + s);
default -> System.out.println("Other");
}You can also combine it: case null, default -> ....
Guarded Patterns with the When Clause
Sometimes matching a type is not enough. You want to match a type AND verify some additional condition about the value. Before Java 21, you would match the type and then add an if statement inside the case body:
java
switch (obj) {
case String s -> {
if (s.contains("h") || s.contains("H")) {
System.out.println("Contains h");
} else {
// fall through to next case? Not easily done here.
}
}
}The problem is that once you are inside the case body, you cannot cleanly fall through to the next case. You are stuck handling it there or doing something awkward.
Java 21 solves this with the when clause. You add it directly to the case label:
java
switch (obj) {
case String s when s.contains("h") || s.contains("H") ->
System.out.println("String containing h: " + s);
case String s ->
System.out.println("String without h: " + s);
default ->
System.out.println("Not a string");
}Read the first label: "if obj is an instance of String AND the string contains h or H." Both conditions must be true for that case to match. If the object is a String but does not contain h or H, execution moves to the second case String s which has no when clause and therefore matches any remaining String.
The when clause is always an AND relationship. The type pattern must match first, and then the when condition is evaluated. This mirrors how pattern matching for instanceof worked: you could use && to add conditions but not || between the type check and the extra condition.
To summarize the logic model:
case Type variable when condition -> bodyMeans: is the switch value an instance of Type? If yes, cast and bind to variable. Then evaluate condition. If both pass, execute body. Otherwise move to the next case.
Here is a practical example:
java
Object obj = 42;
String description = switch (obj) {
case Integer i when i < 0 -> "negative integer";
case Integer i when i == 0 -> "zero";
case Integer i when i > 0 -> "positive integer";
case String s when s.isBlank() -> "blank string";
case String s -> "non blank string: " + s;
default -> "something else";
};
System.out.println(description); // prints: positive integerExhaustiveness and Sealed Hierarchies
Pattern switch works well with sealed classes and interfaces (previous chapter).
When you switch on a sealed type, Java knows at compile time every possible subtype that can exist. It can therefore verify that your switch covers all of them. If you forget one, the code will not compile.
java
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed! Compiler verified all cases are covered.
};
}If you add a new subtype to the Shape sealed interface and forget to add a case for it here, the code stops compiling. The compiler acts as your completeness checker, preventing the subtle runtime bugs you would get if you used a traditional class hierarchy with a default that silently swallowed the unhandled new type.
Without sealed types, you need a default case because the compiler cannot know what types might exist:
java
// Non-sealed hierarchy: must have default
switch (obj) {
case String s -> ...
case Integer i -> ...
default -> ... // required
}With sealed types: default is optional if your cases are exhaustive.
Combining Everything: A Real Example
Here is an example that brings together type patterns, guarded patterns, a sealed type, and null handling:
java
sealed interface Notification permits EmailNotification, SmsNotification, PushNotification {}
record EmailNotification(String to, String subject, String body) implements Notification {}
record SmsNotification(String phoneNumber, String message) implements Notification {}
record PushNotification(String deviceToken, String title, boolean urgent) implements Notification {}
void send(Notification n) {
switch (n) {
case null ->
System.out.println("Cannot send null notification");
case EmailNotification e when e.body().isBlank() ->
System.out.println("Email to " + e.to() + " has empty body, skipping");
case EmailNotification e ->
System.out.println("Sending email to " + e.to() + " subject: " + e.subject());
case SmsNotification sms ->
System.out.println("Sending SMS to " + sms.phoneNumber() + ": " + sms.message());
case PushNotification push when push.urgent() ->
System.out.println("URGENT push to device " + push.deviceToken() + ": " + push.title());
case PushNotification push ->
System.out.println("Push to device " + push.deviceToken() + ": " + push.title());
}
}This is clean, safe, and exhaustive. Adding a new Notification subtype to the sealed interface would immediately break this compile, forcing you to handle it. The when clauses let you handle edge cases inline without nested if statements cluttering the body.
Check yourself
- Before Java 21, which types could a switch work on? What does Java 21 add?
- What does
case String s ->do? - Is a pattern switch over objects faster than an
if/else ifchain ofinstanceof? - Can you use a pattern variable
sfrom one case in another case or after the switch? - The selector is a
TwoWheeler. Why iscase FourWheeler frejected, and why does having bothcase TwoWheeler tandcase Vehicle vfail? - Can you write
case Circle c: case Square s:together? objisnulland the switch has only type cases plusdefault. What happens?- What does
whenadd, and does it behave like AND or OR?
Answers
byte,short,char,int, their wrappers,Stringand enums. Java 21 allows any reference type with type patterns.- If the value is a
String, it is cast and bound tosfor that case. - No, it is a sequence of type tests; the benefit is readability and compiler checks.
- No, it is scoped to its own case.
- A
TwoWheelercan never be aFourWheeler.TwoWheeler tandVehicle vboth match every remaining value: "duplicate unconditional pattern". - No, cases that declare pattern variables cannot be grouped.
NullPointerException; onlycase nullhandles null.- An extra condition after the type matches; it acts like AND.