Java Pattern Matching: 10 Proven Techniques to Write Safer, Cleaner Code
Master Java pattern matching with 10 practical patterns that eliminate ClassCastExceptions and reduce boilerplate. Learn safer, cleaner code today.
I remember the first time I chased a ClassCastException through a tangled web of if‑else blocks. Hours later, I found the root cause—a forgotten cast after an instanceof check that was only true in one branch. That mistake cost me a day. Pattern matching in Java fixes exactly that kind of error. It lets you write code that is shorter, safer, and easier to read. Over the years, I have collected ten patterns that I use almost every day. Let me walk you through them one at a time.
The simplest pattern is also the one you will use most often. Instead of writing if (obj instanceof String) and then casting inside the block, you can combine the check and the cast into a single expression. The pattern variable is only visible inside the if block, and the compiler does the cast for you.
// Old way
if (obj instanceof String) {
String s = (String) obj;
System.out.println("Length: " + s.length());
}
// Using pattern matching
if (obj instanceof String s) {
System.out.println("Length: " + s.length());
}
I love this pattern because it removes a whole class of mistakes. You never forget to cast, and you never accidentally use the wrong variable. The pattern variable s is only in scope when the check passes, so there is no chance of using an uninitialized or wrongly cast variable.
You can extend this idea with boolean conditions using &&. Because the pattern variable is already bound when you reach the &&, you can safely use it in the second condition.
if (obj instanceof String s && s.length() > 5) {
System.out.println("Long string: " + s);
}
Notice that you cannot use || with a pattern variable. The compiler knows that if the first condition is false and we check the second part of an ||, the variable might not be bound. So stick with && when you combine patterns with other tests.
The real power of pattern matching shows when you use it with switch statements and expressions. Before Java 17, you wrote long chains of if (shape instanceof Circle), else if (shape instanceof Rectangle), etc. Now you can write a single switch that matches on type.
public String describeShape(Shape shape) {
return switch (shape) {
case Circle c -> "Circle with radius " + c.radius();
case Rectangle r -> "Rectangle " + r.width() + "x" + r.height();
case Triangle t -> "Triangle with area " + t.area();
case null -> "No shape";
};
}
The switch is cleaner than the if‑else chain, and the compiler can check that you have covered all possibilities if the hierarchy is sealed.
Sealed classes and interfaces tell the compiler exactly which subclasses exist. When you combine them with pattern matching, you get compile‑time safety that is hard to match. If you add a new subclass, the switch will not compile until you handle it.
public sealed interface Payment permits CreditCard, PayPal, Crypto {}
public record CreditCard(String lastFour, BigDecimal amount) implements Payment {}
public record PayPal(String email, BigDecimal amount) implements Payment {}
public record Crypto(String wallet, BigDecimal amount) implements Payment {}
public String process(Payment payment) {
return switch (payment) {
case CreditCard c -> "Processing card ending " + c.lastFour();
case PayPal p -> "Processing PayPal account " + p.email();
case Crypto c -> "Processing crypto wallet " + c.wallet();
};
}
I purposely left out a default branch. The compiler does not complain because every permitted subtype is covered. If I later add a BankTransfer class and forget to update the switch, the code will not compile. That is the kind of safety I rely on.
Record patterns let you destructure a record directly inside a pattern. You can nest them as deep as you need. The var keyword inside the pattern means you do not have to write the type explicitly for components you do not care about.
record Point(int x, int y) {}
record Circle(Point center, int radius) {}
public String describe(Shape shape) {
return switch (shape) {
case Circle(Point(var x, var y), var r) ->
"Circle at (" + x + "," + y + ") radius " + r;
default -> "Unknown shape";
};
}
If the shape is a Circle, the pattern binds x, y, and r to the corresponding fields. I can reuse the same pattern with different guard conditions without writing manual extraction code.
Guards are the when clause that comes after a pattern. They let you filter matched values further. Instead of writing an if inside the case body, you attach the condition directly to the pattern.
public String classifyOrder(Order order) {
return switch (order) {
case Order(var id, var total) when total.compareTo(BigDecimal.valueOf(1000)) > 0
-> "Large order: " + id;
case Order(var id, var total) -> "Standard order: " + id;
case null -> "Invalid";
};
}
The guard uses the pattern variables (total here). The second case Order without a guard acts as a catch‑all for orders that do not meet the large order threshold. The double case pattern works because the compiler tries them in order.
Null handling in pattern matching deserves special attention. The instanceof pattern does not match null. In a switch expression, you must include a case null if you want to handle null without throwing an exception.
public String safeDescribe(Object obj) {
return switch (obj) {
case String s -> "String: " + s;
case Integer i -> "Integer: " + i;
case null -> "null";
default -> "Unknown";
};
}
If you omit the case null and pass a null, the switch throws a NullPointerException before it reaches the default. I always think about whether null is a valid input for my method and add the null case accordingly.
Pattern cases must be ordered from most specific to most general. If you put a case for a supertype before a case for a subtype, the subtype case will never be reached, and the compiler will warn you about it.
// This will not compile
public String test(Object obj) {
return switch (obj) {
case CharSequence cs -> "CharSequence";
case String s -> "String"; // Error: dominated
default -> "Other";
};
}
To fix it, swap the order. The String case must come before the CharSequence case because every String is also a CharSequence. The same rule applies to record patterns – a generic record pattern is less specific than a pattern with a constant component.
Pattern matching for Optional is not directly supported because Optional is not a sealed class that the compiler can exhaustively enumerate. I usually stick with the traditional methods like ifPresentOrElse or convert the optional into a more structured type when I need pattern matching.
Optional<String> opt = getOptional();
opt.ifPresentOrElse(
s -> System.out.println("Value: " + s),
() -> System.out.println("Empty")
);
If you really want pattern matching, you can wrap the optional in a sealed hierarchy yourself, but for daily work the existing API is enough.
Testing pattern matching code is straightforward, but you must cover all branches. I write parameterized tests that include null, every subtype, and edge cases for guards.
@ParameterizedTest
@CsvSource({
"Circle(Point(0,0),10), Circle at (0,0) radius 10",
"null, No shape"
})
void testDescribeShape(Shape shape, String expected) {
assertEquals(expected, shapeProcessor.describe(shape));
}
For guards, test values exactly on the boundary. For the order classification, test with total = 1000 (should be standard) and total = 1000.01 (should be large). The bug often lives at the boundary.
Start with the simplest pattern – replacing instanceof and cast – and gradually adopt the others. Each pattern removes a whole category of mistakes. The compiler becomes your safety net, and your code becomes shorter and clearer. That is the kind of change that makes you wonder how you ever lived without it.