Pattern Matching in Java - CryptoPayment MatchException
MatchException: no case matched CryptoPayment due to missing sealed permits.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- Pattern matching binds a variable in the same step as type check: shorter, safer code
- instanceof pattern:
if (obj instanceof String s)replacesif (obj instanceof String)+String s = (String) obj - Switch expressions with patterns allow complex dispatch in one expression, with guards (
when) and exhaustive checks - Sealed classes restrict subtyping, enabling compiler-verified exhaustive switch—no more default branches needed
- Performance: pattern matching often compiles to same bytecode as manual casts; no runtime overhead for simple patterns
- Biggest production gotcha: forgetting sealed+permits on a class hierarchy breaks exhaustive checks, leading to MatchException at runtime
Pattern matching is a language feature that combines type checking with variable binding in a single operation. Instead of writing:
``java
if (obj instanceof String) {
String s = (String) obj;
// use s
}
``
You write:
``java
if (obj instanceof String s) {
// use s directly
}
``
This is not just shorter — it eliminates a whole class of bugs where the cast fails because the type changed between check and cast. The binding variable is only in scope if the pattern matches, so you can't accidentally use it outside the branch.
The feature evolved through multiple Java versions:
- Java 16: Pattern matching for instanceof (preview in 14, final in 16)
- Java 17: Sealed classes (final)
- Java 19: Record patterns (preview)
- Java 21: Pattern matching for switch (final), record patterns (final)
Each step widens the scope: from simple type checks to complex data structure destructuring and exhaustive dispatch.
Imagine you work at a post office sorting packages. Every package arrives in an unmarked box. Old-school you would pick up the box, shake it, read a label, set it down, pick it up again, and finally open it. Pattern matching is like having a magic scanner that reads the label AND opens the box in one motion. In Java terms: you used to check what type an object was and then cast it separately. Pattern matching checks the type AND gives you a ready-to-use variable in one line — no redundant ceremony.
Every Java codebase written before Java 16 has at least one method that looks like a long chain of instanceof checks followed by explicit casts. It works, but it's noisy, repetitive, and a quiet source of bugs — cast the wrong type and you get a ClassCastException at runtime with no compiler warning. The real cost isn't the extra line; it's the cognitive overhead of mentally tracking which type you've already confirmed while reading through a wall of if-else branches.
Pattern matching was introduced to solve exactly this friction. It lets the compiler carry the type knowledge forward so you don't have to. Instead of check → cast → use (three steps), you get check-and-bind (one step). This isn't just syntactic sugar — it's a language-level guarantee: the binding variable is only in scope where the compiler can prove the type holds, which eliminates an entire class of runtime errors.
By the end of this article you'll understand how pattern matching for instanceof works at the bytecode level, how switch pattern matching (Java 21) lets you write exhaustive, compiler-verified dispatch logic, how sealed classes and records compose with patterns to build airtight domain models, and what production gotchas can bite you even when everything compiles cleanly. We'll go deep — this is the stuff that separates developers who know the syntax from those who understand the design.
What is Pattern Matching in Java?
Pattern matching is a language feature that combines type checking with variable binding in a single operation. Instead of writing:
``java if (obj instanceof String) { String s = (String) obj; // use s } ``
You write:
``java if (obj instanceof String s) { // use s directly } ``
This is not just shorter — it eliminates a whole class of bugs where the cast fails because the type changed between check and cast. The binding variable is only in scope if the pattern matches, so you can't accidentally use it outside the branch.
- Java 16: Pattern matching for
instanceof(preview in 14, final in 16) - Java 17: Sealed classes (final)
- Java 19: Record patterns (preview)
- Java 21: Pattern matching for switch (final), record patterns (final)
Each step widens the scope: from simple type checks to complex data structure destructuring and exhaustive dispatch.
package io.thecodeforge.matching; // Traditional approach vs pattern matching public class ShapeMatcher { public static void main(String[] args) { Object shape = new Circle(5.0); // Old way if (shape instanceof Circle) { Circle c = (Circle) shape; System.out.println("Area: " + c.area()); } // Pattern matching if (shape instanceof Circle c) { System.out.println("Area: " + c.area()); } } static class Circle { double radius; Circle(double r) { radius = r; } double area() { return Math.PI * radius * radius; } } }
c is only in scope within the if block. This is enforced at compile time, so you cannot accidentally use it after the block or in the else branch. This scoping is a safety guarantee that manual casts lack.instanceof pattern in an if statementwhen clauseInstanceof Pattern Matching in Depth
The simplest form of pattern matching is the instanceof pattern, stabilized in Java 16. It allows you to write:
``java if (obj instanceof String s && ``s.length() > 5) { // s is a String and length > 5 }
Note the conjunction: the pattern variable s is available in the subsequent conditions of the same expression due to flow scoping. This works with &&, ||, and negation (!).
Flow scoping means the compiler tracks when a variable is definitely matched. For example: ``java if (!(obj instanceof String s)) { // s is NOT in scope here } // s is NOT in scope here either, because we can't guarantee it matched ``
But if you combine with ||: ``java if (obj instanceof String s || obj instanceof Integer i) { // Here neither s nor i is reliably in scope because either could be true } `` The compiler is conservative — it only allows pattern variables where they are guaranteed to be bound on all paths leading to that code point.
This precision means you can write complex type checks without worrying about variable leaks.
package io.thecodeforge.matching; public class FlowScopingDemo { public static void main(String[] args) { Object obj = "hello world"; // Works: pattern variable in scope in then-block if (obj instanceof String s && s.length() > 3) { System.out.println("Long string: " + s); } // Does NOT compile: pattern variable not definitely assigned // if (obj instanceof String s || obj instanceof Integer i) { // System.out.println(s); // compile error // } // Negation with explicit scope if (!(obj instanceof String s)) { // s not in scope here System.out.println("Not a string"); } else { // s is in scope here: compiler knows it matched System.out.println("String length: " + s.length()); } } }
! with instanceof pattern, remember that the pattern variable is NOT in scope in the true branch (the negation branch). It IS in scope in the else branch. This is counterintuitive for many developers.if (obj instanceof Type var)if (obj instanceof Type var && var.isActive())if (!(obj instanceof Type var)) then handle in elseSwitch Pattern Matching and Exhaustiveness
Java 21 brought pattern matching to switch statements and expressions. This is where the real power lies: you can now match on multiple patterns, including guards (using when), and the compiler will check that all cases are covered.
Basic switch pattern matching: ``java String formatted = switch (shape) { case Circle c -> "Circle radius: " + ``c.radius(); case Rectangle r -> "Rectangle area: " + r.area(); case Square s -> "Square side: " + s.side(); default -> "Unknown shape"; };
But the real game-changer is exhaustiveness with sealed classes. If your hierarchy is sealed, you can omit the default clause: ```java sealed interface Shape permits Circle, Rectangle, Square { }
String formatted = switch (shape) { case Circle c -> "Circle radius: " + c.radius(); case Rectangle r -> "Rectangle area: " + r.area(); case Square s -> "Square side: " + s.side(); // No default needed — compiler knows all cases covered }; ``` If you later add a new subtype to the permits clause, the switch will fail to compile until you add the missing case. This is a powerful safety net.
Guards allow you to add extra conditions: ``java case Shape s when `` The guard is evaluated after the pattern matches. If the guard fails, the next case is tried.s.area() > 100 -> "Large shape: " + s;
Total patterns like case Object o act as a catch-all when you cannot use sealed classes. But they disable exhaustiveness checking — use sparingly.
package io.thecodeforge.matching; public class SwitchPatternDemo { sealed interface Shape permits Circle, Rectangle, Square { } record Circle(double radius) implements Shape { double area() { return Math.PI * radius * radius; } } record Rectangle(double width, double height) implements Shape {\n double area() { return width * height; } } record Square(double side) implements Shape { double area() { return side * side; } } public static void main(String[] args) { Shape shape = new Circle(5.0); String description = switch (shape) { case Circle c && c.radius() > 3 -> "Big circle"; case Circle c -> "Small circle"; case Rectangle r -> "Rectangle"; case Square s -> "Square"; // No default needed }; System.out.println(description); } }
- Without sealed: switch needs default or total pattern — open world
- With sealed: compiler enforces all branches — no default needed
- Adding a new subtype requires updating all switches — but that's a feature, not a bug
- Guard conditions narrow the match but still within the sealed set
tableswitch or lookupswitch bytecode, same as traditional switch on enum. No boxing overhead.when guard clausecase Object o total pattern or explicit defaultRecord Patterns and Destructuring
Record patterns (final in Java 21) allow you to destructure a record into its components directly in a pattern match. This is especially powerful when combined with switch:
```java record Point(int x, int y) { } record Line(Point start, Point end) { }
String describe(Object obj) { return switch (obj) { case Point(int x, int y) -> "Point at (" + x + ", " + y + ")"; case Line(Point(var x1, var y1), Point(var x2, var y2)) -> "Line from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")"; default -> "Unknown"; }; } ```
Note the use of var in nested patterns — you can omit the type when it's clear from the record component type. You can also use guards on the destructured values: ``java case Point(int x, int y) when x == y -> "Diagonal point"; ``
Record patterns work with both switch and instanceof: ``java if (obj instanceof Point(int x, int y)) { System.out.println("Point at " + x + ", " + y); } ``
This is a massive reduction in boilerplate for data-centric code — no more manual getter calls or if-null checks.
package io.thecodeforge.matching; public class RecordPatternDemo { record Point(int x, int y) { } record Circle(Point center, double radius) { } public static void main(String[] args) { Object obj = new Circle(new Point(3, 4), 5.0); String desc = switch (obj) { case Circle(Point(var x, var y), var r) when r > 10.0 -> "Large circle at (" + x + "," + y + ")"; case Circle(Point(var x, var y), var r) -> "Circle at (" + x + "," + y + ") radius " + r; case Point p -> "Point at (" + p.x() + "," + p.y() + ")"; default -> "Unknown"; }; System.out.println(desc); } }
var for component types to keep the pattern readable. The compiler infers the type from the record component declaration. Only specify the type explicitly if you want to narrow it (e.g., if the component is declared as Object).-XX:+ShowPattern VM flag to see how the JVM compiles record patterns.var keep code clean.when guardProduction Gotchas and Performance Considerations
Pattern matching in Java is designed to be efficient, but there are nuances:
- Pattern ordering matters: In switch, patterns are tried in order. If you have overlapping patterns (e.g.,
Number nbeforeInteger i), the more specific pattern will never match. The compiler warns about unreachable code in simple cases but not in all. - Guard evaluation order: The guard is evaluated after the pattern matches. If the guard throws an exception, it propagates as if it were any other runtime exception. Guards that depend on mutable state can cause nondeterministic behaviour.
- Null handling: In switch expressions,
nulldoes not match any pattern unless you have an explicitcase null. Before Java 21, switch threw NullPointerException. Now you can handle null: - ```java
- switch (obj) {
- case null -> "null";
- case String s -> s;
- // ...
- }
- ```
- Performance: For switch over sealed classes with a small number of subtypes (≤ 10), the JVM uses
tableswitchwhich is O(1). For larger sets or non-sealed hierarchies, it may fall back tolookupswitchor a series of instanceof checks. In practice, the performance difference is negligible for most code. - Bytecode size: Record patterns and nested patterns can lead to significantly larger bytecode because each component becomes a getter call. This is rarely a problem but can affect startup time on class-loading-heavy applications.
- Backwards compatibility: Pattern matching on existing legacy code may introduce subtle changes if you refactor a long if-else chain. Always test thoroughly.
package io.thecodeforge.matching; public class NullHandlingDemo { public static void main(String[] args) { Object obj = null; String result = switch (obj) { case null -> "Received null"; case String s -> "String: " + s; case Integer i -> "Integer: " + i; default -> "Unknown"; }; System.out.println(result); } }
case null branch. If you don't, it still throws NPE. Always add a null case when switching over nullable objects.Pattern Matching Syntax — What You Actually Write vs. Regex Confusion
New devs often confuse pattern matching in Java with regex patterns from java.util.regex. They're completely different. The pattern() method on Matcher returns a Pattern object for regex — it has nothing to do with instanceof or switch pattern matching introduced in Java 16+. In production, I've seen teams waste hours debugging because someone assumed Pattern.matches() would work like instanceof pattern matching. It won't. Pattern matching in Java 21 uses instanceof with a variable binding: if (obj instanceof String s) — that's the syntax. No regex involved. The compiler checks types, not text. When you see matcher.pattern(), you're looking at a compiled regex, not a type pattern. Keep them separate in your head. One matches text strings against wildcards. The other matches object types against Java's type hierarchy. Your IDE's autocomplete will suggest both. Know which one you need before you type.
// io.thecodeforge import java.util.regex.Pattern; public class PatternVsRegex { public static void main(String[] args) { // This is REGEX — NOT pattern matching Pattern regex = Pattern.compile("G.*s$"); String input = "GeeksForGeeks"; System.out.println("Regex matches: " + regex.matcher(input).matches()); Object obj = "GeeksForGeeks"; // This is PATTERN MATCHING (Java 16+) if (obj instanceof String s && s.startsWith("G")) { System.out.println("Pattern matched string: " + s); } } }
Pattern.matches() for type checking. It returns false if the entire string doesn't match the regex, silently swallowing null. Use instanceof pattern matching instead — it's null-safe and type-safe.Exhaustiveness — Why Your Switch Compiles but Blows Up at Runtime
Switch pattern matching in Java 21 requires exhaustiveness when you use sealed classes or enums. The compiler forces you to handle all subtypes. But here's the production trap: if you use a plain interface without sealed, the compiler cannot enforce exhaustiveness. Your switch compiles fine. Your tests pass. Then at 3 AM, a new implementation gets introduced via classpath scanning or reflection, and your switch silently falls through or throws a MatchException. I fixed this exact bug in a payment routing service. The team added a new payment provider enum value but forgot to update the switch. In production, unmatched patterns throw MatchException at runtime. The fix: always add a default case even when you think the compiler has your back. Use sealed classes for domain types you control. For external interfaces, write a unit test that uses reflection to verify all implementations are covered. Your future self will thank you when the on-call phone stays silent.
// io.thecodeforge sealed interface Payment permits CreditCard, PayPal, Crypto {} record CreditCard(String cardNum) implements Payment {} record PayPal(String email) implements Payment {} record Crypto(String wallet) implements Payment {} public class ExhaustiveSwitch { public static String process(Payment p) { return switch(p) { case CreditCard c -> "Processing card ending " + c.cardNum().substring(12); case PayPal e -> "Sending invoice to " + e.email(); case Crypto w -> "Confirming wallet " + w.wallet(); // Uncomment below or get MatchException at runtime // default -> throw new IllegalArgumentException("Unknown payment: " + p); }; } public static void main(String[] args) { System.out.println(process(new CreditCard("1234567890123456"))); } }
default as a safety net: default -> throw new IllegalStateException("Unexpected value: " + p)Nested Record Patterns — Destructuring Without the Boilerplate
Record patterns let you destructure nested objects in a single line. Before Java 21, extracting values from deeply nested records meant five lines of null checks and getter calls. Now you write if (obj instanceof Order(var id, Address(var street, var city))) and the compiler extracts everything. But here's the production reality: the compiler generates synthetic accessor methods for record components. If your record has complex validation in the constructor, those accessors still run on every match. I flattened a Logstash-shaped slowdown by caching a deeply nested record pattern match that was being called on every HTTP request. The fix: match early, destructure once, pass the extracted values. Also, nullable record components break pattern matching — a null in a nested position causes the match to fail silently. If your JSON deserialization sets fields to null (looking at you, Jackson), your pattern matching will never trigger. Validate your data before attempting pattern matches on it.
// io.thecodeforge record Address(String street, String city) {} record Order(long id, Address address) {} public class NestedRecords { public static String extractCity(Object obj) { // Before Java 21: 5 lines of null checks // Java 21: one line destructuring return switch (obj) { case Order(var id, Address(var street, var city)) -> "Order " + id + " ships to " + city; case null -> "Null object received"; default -> "Not an order"; }; } public static void main(String[] args) { var order = new Order(42L, new Address("123 Main", "Springfield")); System.out.println(extractCity(order)); } }
case Order(var id, Address(var street, var city)) when street != nullMidnight MatchException in a Payment Dispatcher
java.lang.MatchException: no case matched: class io.thecodeforge.payment.CryptoPayment. The exception was uncaught because developers assumed the switch was exhaustive.CryptoPayment subclass but forgot to add it to the permits clause of the sealed interface.Payment had permits listing only CreditCardPayment and PayPalPayment. The new CryptoPayment extended Payment without being added to permits, so the compiler didn't know about it. The switch in the dispatcher was exhaustive based on the known subtypes, but at runtime the JVM encountered an unknown subtype and had no matching case.CryptoPayment to the permits clause of the sealed interface.
2. Move the dispatcher switch to use a library-level helper that logs a warning on unknown subtypes to prevent silent failure.
3. Add a unit test that calls the dispatcher with every known subtype to catch exhaustiveness breakage early.- Sealed classes enforce exhaustiveness only for compiler-known subtypes. If you add a subtype outside the permits list, the switch still compiles but silently breaks at runtime.
- Always keep the permits list in sync with actual subtypes. Use compiler annotations or architectural tests to verify.
- Consider using library-level exhaustiveness checks with a default case that logs and rethrows for truly unknown subtypes.
default -> throw new RuntimeException(...) in dev to catch missing cases early.String s and CharSequence c where String implements CharSequence) result in the first match. Reorder or use guards to disambiguate.javap -c -p io.thecodeforge.payment.PaymentDispatcherjavap -verbose io.thecodeforge.payment.Payment | grep -i patternsjar tf payment-service-1.0.jar | grep Paymentgrep -r 'permits' src/main/java/io/thecodeforge/payment/grep -A5 'default ->' src/main/java/io/thecodeforge/find . -name '*.java' -exec grep -l 'permits' {} \;| Feature | Java Version | Purpose | Production Use Case |
|---|---|---|---|
| Instanceof pattern | 16 (final) | Type check and bind variable in one step | Replacing every manual type-check + cast pattern |
| Switch with patterns | 21 (final) | Exhaustive dispatch over sealed types with guards | Replacing long if-else chains in business logic |
| Record patterns | 21 (final) | Destructure records directly in pattern match | Data processing with domain models (orders, users) |
| Sealed classes + permits | 17 (final) | Restrict type hierarchy to known subtypes | Domain modeling with compile-time exhaustiveness |
| Guard (when) clauses | 21 (final) | Add extra condition after pattern match | Refining pattern matches with additional state checks |
| Null handling in switch | 21 (final) | Explicit null branch in switch expressions | Safe dispatching on nullable values |
Key takeaways
Common mistakes to avoid
5 patternsUsing pattern variables outside their scope
Overlapping patterns in switch
Number n before Integer i).Forgetting to handle null in switch expressions
case null.case null -> branch if the operand can be null. If null is unexpected, use case null -> throw new IllegalArgumentException("Unexpected null").Not updating permits when adding a new subtype
Expecting record patterns to work on regular classes
Interview Questions on This Topic
Explain flow scoping in the context of instanceof pattern matching. Can you use a pattern variable in the else branch?
if (obj instanceof String s), the variable s is in scope inside the true branch but not in the false branch. However, if you use ! negation: if (!(obj instanceof String s)), then s is NOT in scope in the true branch (the negation branch), but IS in scope in the else branch. This is because the else branch means the pattern matched. The compiler is conservative: it only allows variables where all paths guarantee the match.What is the purpose of sealed classes in pattern matching? How do they affect switch exhaustiveness?
permits clause. When you use a sealed class as the selector expression in a switch with patterns, the compiler can verify that all subtypes are covered. If you omit a case for a permitted subtype, the switch fails to compile. This eliminates the need for a default branch and ensures that adding a new subtype forces you to update all switch statements and expressions that dispatch on it. It's a compile-time safety net for exhaustiveness.How does the JVM compile a switch expression with record patterns? What is the bytecode overhead?
tableswitch or lookupswitch for the outer dispatch on the record type. Overall, for practical use cases, the overhead is negligible compared to the manual code you would write.What happens if a guard clause throws an exception? Does the switch try the next case?
Can pattern matching be used with primitives? Explain.
case int i in a switch because int is a primitive. However, you can use pattern matching on the wrapper types (Integer, Long) with the understanding that autoboxing occurs. In practice, for primitive-heavy dispatch, enums or traditional switch are still the right tools. Record patterns also work only with record types (reference types).Frequently Asked Questions
Both compile to similar bytecode, but pattern matching eliminates the manual cast and limits the scope of the binding variable. With traditional code, you could accidentally use the variable outside the if block or in a branch where the cast might fail. Pattern matching's flow scoping prevents this at compile time.
Yes. Switch on enums works as before, but you can combine with patterns if the enum implements an interface that has other subtypes. However, since enums are implicitly final, pattern matching adds little benefit over traditional switch on enums. The real power is with sealed classes and records.
Yes, record patterns are finalized in Java 21. You can use them in both switch and instanceof. They were preview in Java 19 and 20.
Pattern matching matches based on the runtime type of the object. Anonymous classes are concrete classes that extend a parent, so yes, they can match patterns. However, if a pattern expects a specific subtype, the anonymous class must be assignable to that type. Flow scoping works the same way.
Sealed classes affect serialization only in the sense that you must be careful when deserializing objects that may be subtypes not known at compile time (e.g., from old clients). If you add a new permitted subtype, old serialized objects won't match any pattern, causing MatchException. Versioning strategies (like a version field) can mitigate this.
No built-in array patterns in Java as of JDK 21. You cannot write case int[] arr. Arrays are still matched by reference, not destructured. This is a planned feature (deconstruction patterns for arrays) but not yet available.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Java 8+ Features. Mark it forged?
8 min read · try the examples if you haven't