Mastering Java's New Switch Expressions & Pattern Matching for Cleaner Code
Java’s switch expressions and pattern matching (instanceof, switch, record patterns) are some of the biggest language improvements since lambdas.
They turn verbose if-else chains into expressive, concise and type-safe code.
- How modern
switchevolved (Java 12–21) - Switch expressions vs classic switch (and when to use which)
- Pattern matching with
instanceofandswitch - Record patterns + sealed classes for fully exhaustive switches
- Handling
null, guards (when), dominance and exhaustiveness - Refactoring real-world legacy code to modern style
- Best practices, performance notes and common pitfalls
1. Quick Overview — What Changed in Java?
| Java Version | Key Feature | JEP |
|---|---|---|
| Java 12–14 | Switch expressions (yield, arrow syntax) |
JEP 325, 354, 361 |
| Java 16 | Pattern matching for instanceof (standard) |
JEP 394 |
| Java 17–20 | Pattern matching for switch (previews) |
JEP 406, 420, 427, 433 |
| Java 21 | Pattern matching for switch (final) + record patterns |
JEP 441 & JEP 440 |
We’ll assume at least Java 17+, and highlight where some features only become final in Java 21.
2. Classic Switch vs Modern Switch Expressions
2.1. Old, Statement-Only Switch
String message;
switch (status) {
case 200:
message = "OK";
break;
case 404:
message = "Not Found";
break;
default:
message = "Unknown";
}
Problems:
- Boilerplate
break;everywhere (fall-through bugs). - Not an expression → you can’t directly return from it.
- Only works with limited selector types (int, String, enum, etc.).
2.2. Switch Expressions — Arrow Syntax
String message = switch (status) {
case 200 -> "OK";
case 301, 302 -> "Redirect";
case 404 -> "Not Found";
default -> "Unknown";
};
Key differences:
- Expression: returns a value (you can assign or return it).
- No fall-through with arrow (
->) syntax. - Exhaustiveness check for switch expressions: all cases must be covered (especially for enums).
2.3. Using yield for Multi-Statement Cases
String message = switch (status) {
case 200 -> "OK";
case 301, 302 -> {
logRedirect(status);
yield "Redirect";
}
default -> "Unknown";
};
Use yield instead of return inside a switch expression block. It gives the value of the switch.
3. Pattern Matching for instanceof — Foundation
Before we jump into switch patterns, let’s start with the simpler form: pattern matching for instanceof.
3.1. Before Java 16
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
} else if (obj instanceof Integer) {
Integer i = (Integer) obj;
System.out.println(i + 1);
}
3.2. With Pattern Matching
if (obj instanceof String s) {
System.out.println(s.toUpperCase());
} else if (obj instanceof Integer i) {
System.out.println(i + 1);
}
Benefits:
- No duplicated type name & unsafe casts.
- Cleaner and more readable.
- Compiler ensures
s/iare only used where valid.
3.3. Guarded Patterns with if
if (obj instanceof String s && s.length() > 5) {
System.out.println("Long string: " + s);
}
4. Pattern Matching for Switch — The Big Upgrade
Pattern matching for switch (final in Java 21) lets you:
- Use type patterns in
caselabels. - Handle
nullexplicitly. - Use guards (
when) for extra conditions. - Get compile-time checks for exhaustive coverage.
4.1. Basic Type Patterns in Switch
static String formatObject(Object obj) {
return switch (obj) {
case null -> "null";
case String s -> "String: " + s.toUpperCase();
case Integer i -> "Integer: " + (i + 1);
case Double d -> "Double: " + String.format("%.2f", d);
case int[] arr -> "int array of size " + arr.length;
default -> "Unknown type: " + obj;
};
}
Notes:
switchnow works on any reference type, not just int/String/enum. :contentReference[oaicite:0]{index=0}nullis allowed as a dedicated case.- Cases are evaluated top-down, with dominance checks (more on that below).
4.2. Guarded Patterns with when
static String describeNumber(Number n) {
return switch (n) {
case null -> "null";
case Integer i when i >= 0 -> "positive int: " + i;
case Integer i -> "negative int: " + i;
case Long l when l > 1_000_000 -> "big long: " + l;
case Long l -> "long: " + l;
default -> "other number: " + n;
};
}
whenadds an extra boolean condition on top of the type match.- A pattern only matches if both the type and the guard succeed.
4.3. Dominance — Avoiding Dead Cases
The compiler checks that cases are not unreachable. This is called pattern dominance. :contentReference[oaicite:1]{index=1}
// ❌ Compile error: second case is dominated by the first
static String bad(Object obj) {
return switch (obj) {
case Object o -> "object";
case String s -> "string: " + s; // unreachable
};
}
Fixed version:
static String good(Object obj) {
return switch (obj) {
case String s -> "string: " + s;
case Object o -> "object";
};
}
4.4. Exhaustiveness
For switch expressions, the compiler ensures that all possible inputs are covered.
enum TrafficLight { RED, YELLOW, GREEN }
static String action(TrafficLight light) {
return switch (light) {
case RED -> "Stop";
case GREEN -> "Go";
case YELLOW -> "Slow down";
// ✅ no default needed – all enum constants covered
};
}
For type-based switches, you usually need a default unless you’re switching over a sealed hierarchy (see section 6).
5. Record Patterns — Deconstructing Data in Switch
Java records are concise data carriers. Record patterns (Java 21, JEP 440) let you deconstruct them right in switch or instanceof. :contentReference[oaicite:2]{index=2}
5.1. Basic Record Pattern
public record Point(int x, int y) { }
static String describePoint(Object obj) {
return switch (obj) {
case Point(int x, int y) -> "Point(" + x + ", " + y + ")";
default -> "Not a point";
};
}
5.2. Nested Record Patterns
public record Point(int x, int y) { }
public record Rectangle(Point topLeft, Point bottomRight) { }
static int area(Object shape) {
return switch (shape) {
case Rectangle(Point(int x1, int y1),
Point(int x2, int y2)) ->
Math.abs(x2 - x1) * Math.abs(y2 - y1);
default -> 0;
};
}
5.3. Record Patterns with Guards
record Order(String id, double amount) { }
static String classifyOrder(Object obj) {
return switch (obj) {
case Order(String id, double amount) when amount > 10_000 ->
"High value order " + id;
case Order(String id, double amount) ->
"Regular order " + id;
default ->
"Not an order";
};
}
Benefits:
- No getters or temporary variables.
- Compiler ensures correct deconstruction order and types.
- Works seamlessly with sealed hierarchies for exhaustive switches.
6. Sealed Types + Pattern Matching — Fully Exhaustive Switches
sealed types let you control which classes implement an interface or extend a class. Combine them with switch pattern matching for exhaustive polymorphic handling. :contentReference[oaicite:3]{index=3}
6.1. Define a Sealed Hierarchy
sealed interface Shape permits Circle, Rectangle, Square { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Square(double side) implements Shape { }
6.2. Exhaustive Switch over Sealed Type
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Square s -> s.side() * s.side();
// ✅ exhaustive – no default needed, compiler knows all subtypes
};
}
If you later add record Triangle(...) implements Shape but forget to update the switch, the compiler will complain — which is exactly what we want.
7. Handling null in Modern Switches
Historically, switch hated null (it threw NullPointerException). Pattern matching for switch makes null first-class.
7.1. Dedicated null Case
static String stringify(Object obj) {
return switch (obj) {
case null -> "<null>";
case String s -> "String: " + s;
default -> obj.toString();
};
}
null explicitly at the top when you expect it. For domain models, design APIs to avoid null in the first place.
8. Refactoring Real-World Legacy Code
8.1. From instanceof + Cast Chain → Pattern Matching + Switch
Before:
String describeShape(Object shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return "Circle: r=" + c.getRadius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return "Rectangle: " + r.getWidth() + "x" + r.getHeight();
} else if (shape instanceof Square) {
Square s = (Square) shape;
return "Square: " + s.getSide();
} else {
return "Unknown shape";
}
}
After (sealed + records + switch patterns):
sealed interface Shape permits Circle, Rectangle, Square { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Square(double side) implements Shape { }
String describeShape(Shape shape) {
return switch (shape) {
case Circle(double r) -> "Circle: r=" + r;
case Rectangle(double w, double h) -> "Rectangle: " + w + "x" + h;
case Square(double side) -> "Square: " + side;
};
}
You get:
- No casting or getters.
- Exhaustive handling enforced by compiler.
- Much more readable domain-oriented code.
8.2. Refactoring Old Switch + Constants
Before:
int priority;
switch (status) {
case "NEW":
priority = 3;
break;
case "IN_PROGRESS":
case "PENDING":
priority = 2;
break;
case "BLOCKED":
priority = 1;
break;
default:
priority = 0;
}
After (expression):
int priority = switch (status) {
case "NEW" -> 3;
case "IN_PROGRESS", "PENDING" -> 2;
case "BLOCKED" -> 1;
default -> 0;
};
9. Best Practices for Switch Expressions & Pattern Matching
9.1. Prefer Switch Expressions Over Statements When You Need a Value
- Use them in
returnor local variable assignment. - They communicate intent better and enforce exhaustiveness.
9.2. Order Cases Carefully (Most Specific → Most General)
- Put narrower types before broader ones (
StringbeforeObject). - Put guarded patterns before unguarded ones.
9.3. Use Sealed Types + Records to Model Domains
- Shape, PaymentMethod, Command, Event, etc.
- Then use pattern matching switches to process them cleanly.
9.4. Avoid Overly Clever Pattern Nests
Deeply nested record patterns can become hard to read. If a pattern is too complex, extract parts into helper methods.
9.5. Keep Business Logic Inside Case Bodies Small
The switch should select behavior, not implement huge workflows. Call service methods from inside the case instead of doing everything inline.
10. Common Pitfalls & How to Avoid Them
- Forgetting default / missing subtype → Let the compiler guide you, especially with sealed types.
- Putting generic patterns before specific ones → Leads to dominance errors or accidentally swallowing cases.
- Mixing statement-style and expression-style mental models → Remember: expression switches must return one and only one value for all paths.
- Ignoring null
→ Decide: either forbid null early or handle
case nullexplicitly.
11. Summary & What to Learn Next
Modern Java gives you powerful tools to write clean, expressive and safer branching logic:
- Switch expressions – concise, no fall-through, exhaustiveness.
- Pattern matching for instanceof – less boilerplate, safer casts.
- Pattern matching for switch – type-based multi-way branching, guards, dominance checks.
- Record patterns + sealed types – domain modeling with compiler-checked exhaustive handling.
If you adopt these features consistently, your service code, domain modeling and even interview solutions become shorter, clearer and easier to maintain.
Next step: Combine these language features with Java 21 records, sealed classes, and Streams to build a truly modern Java style in your entire codebase.