Pattern Matching in Java - CryptoPayment MatchException
MatchException: no case matched CryptoPayment due to missing sealed permits.
- 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
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.
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.
! 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.
- 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.
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.
case null branch. If you don't, it still throws NPE. Always add a null case when switching over nullable objects.Midnight 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.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.Frequently Asked Questions
That's Java 8+ Features. Mark it forged?
6 min read · try the examples if you haven't