Java Pattern Matching: Modern Type-Safe Programming
Java pattern matching capabilities in Java 22 enable concise, type-safe data decomposition that eliminates verbose instanceof checks and manual casting. Therefore, code becomes more readable and less error-prone when working with complex type hierarchies. As a result, Java developers can express data-oriented logic as clearly as functional programming languages, where matching on the shape of data has been idiomatic for decades.
Record Patterns and Deconstruction
Record patterns decompose record instances directly in instanceof checks and switch expressions. Moreover, nested patterns allow deep extraction of values from complex record hierarchies in a single expression. Consequently, data transformation code that previously required multiple variables and casts becomes a single pattern match. Consider the difference concretely: before record patterns, extracting nested fields meant a cascade of instanceof guards, explicit casts, and accessor calls, each an opportunity for a mismatched type or a forgotten null check.
// Before: verbose instanceof, cast, accessor chain
if (event instanceof OrderPlaced op) {
Customer c = op.customer();
if (c instanceof PremiumCustomer pc) {
Address a = pc.shippingAddress();
applyExpressShipping(a, op.total());
}
}
// After (Java 22): nested record patterns deconstruct in one line
if (event instanceof OrderPlaced(PremiumCustomer(_, var addr), var total)) {
applyExpressShipping(addr, total);
}
Generic record patterns preserve type safety through the entire deconstruction chain, so a Box<String> binds its component as a String without an unchecked cast. Furthermore, unnamed patterns with the underscore — standardized in Java 22 under JEP 456 — let you ignore components you do not need, which both documents intent and silences unused-variable warnings. In the example above, the customer’s first field is irrelevant, so _ says so explicitly rather than binding a name that the reader must verify is unused.
Java Pattern Matching with Sealed Types
Sealed classes combined with pattern matching enable exhaustive switch expressions that the compiler verifies for completeness. Additionally, when new subtypes are added to a sealed hierarchy, the compiler flags all switch expressions that need updating. For example, a payment processing system can pattern match on different payment types with guaranteed handling of all variants — and crucially, the safety net is a compile-time error, not a runtime surprise discovered in production.
// Sealed types + pattern matching for exhaustive handling
sealed interface PaymentMethod permits CreditCard, BankTransfer, Crypto, Wallet {}
record CreditCard(String number, String expiry, String cvv) implements PaymentMethod {}
record BankTransfer(String iban, String swift) implements PaymentMethod {}
record Crypto(String walletAddress, String chain) implements PaymentMethod {}
record Wallet(String provider, String accountId) implements PaymentMethod {}
// Exhaustive switch with record patterns
String processPayment(PaymentMethod method, Money amount) {
return switch (method) {
case CreditCard(var num, var exp, _) ->
chargeCard(num, exp, amount);
case BankTransfer(var iban, var swift) ->
initTransfer(iban, swift, amount);
case Crypto(var addr, var chain) when chain.equals("ETH") ->
processEthPayment(addr, amount);
case Crypto(var addr, var chain) ->
processCryptoPayment(addr, chain, amount);
case Wallet(var provider, var id) ->
chargeWallet(provider, id, amount);
};
// No default needed — compiler verifies exhaustiveness
}
Guarded patterns with when clauses add conditional logic within pattern branches. Therefore, complex matching rules stay within the switch expression rather than requiring nested if-else blocks. Note an important subtlety in the example: the guarded Crypto case appears before the unguarded one. Order matters, because the compiler requires that a more specific guarded case precede the catch-all of the same type; reverse them and you get a “dominated case” error. This is the compiler steering you toward correct, reachable code rather than silently ignoring a branch.
The exhaustiveness guarantee is the headline benefit. Avoiding a default branch is not merely stylistic — a default would swallow new subtypes silently. Without it, adding a fifth payment method turns every switch that forgot to handle it into a compilation failure, giving you a precise, project-wide checklist of code to update before the build passes.
Switch Expression Enhancements
Pattern matching in switch supports null handling, primitive patterns, and qualified enum constants. Traditionally, a switch on a null selector threw NullPointerException; now you can write an explicit case null and even combine it as case null, default to fold null into the fallback. However, migration from traditional switch statements requires attention to fall-through behavior differences. In contrast to statement switches, expression switches with the arrow form require exhaustiveness, do not fall through, and return values, which removes an entire class of forgotten-break bugs.
The combination of these features unlocks a genuinely data-oriented style. Rather than modelling every concept as a class with behavior, you can model data as plain records and immutable values, then write the algorithms that operate on that data as switches over sealed shapes. This is the approach the JDK architects describe in their data-oriented programming guidance: keep the data dumb and transparent, and concentrate logic in pattern-matching functions. As a result, an expression evaluator, a JSON visitor, or a protocol decoder reads top to bottom as a set of cases rather than as scattered overridden methods, and the compiler proves you have handled every variant.
There are sharp edges to respect. Pattern binding variables are scoped by flow analysis, so a variable bound in one branch is not visible in another, and a guard that returns early can extend a binding’s scope in ways that surprise newcomers. Likewise, deconstruction calls the record’s accessors, which means a record with side-effecting or expensive accessors will execute that work during matching — a strong argument for keeping records to pure, cheap getters.
When to Reach for Patterns — and When Not To
Pattern matching is a powerful tool, but it is not a universal replacement for polymorphism. The classic object-oriented guidance still holds: if behavior naturally belongs to each type and the set of operations is open, prefer a virtual method on the type itself. A switch that matches on a sealed hierarchy centralizes logic in one place, which is ideal when the operations vary more than the types — serialization, pricing rules, or routing, where you would otherwise scatter the same algorithm across many classes.
The honest trade-off is the axis of change. Sealed-type switches make it easy to add new operations (write another switch) but require touching every switch to add a new type. Virtual methods invert this: new types are cheap, new operations are expensive. As a result, sealed hierarchies and pattern matching pay off most for domain models with a fixed, well-understood set of variants — events, commands, protocol messages, and abstract syntax trees — where the type set rarely changes but the number of things you do with it keeps growing.
Migration Strategy
Adopting pattern matching incrementally starts with replacing instanceof-cast chains with pattern variables — a mechanical, low-risk change that improves readability immediately. Additionally, converting data classes to records enables record pattern usage throughout the codebase, though records impose immutability and a canonical constructor, so reserve the conversion for genuine value types rather than entities with mutable lifecycle. Specifically, sealed hierarchies provide the most value for domain models with fixed type sets like events, commands, and messages. In production, teams typically sequence the migration this way: first flip on pattern instanceof, then seal existing hierarchies to catch missing cases, and only then refactor switches into the expression form once exhaustiveness is guaranteed.
Related Reading:
Further Resources:
In conclusion, Java pattern matching transforms how developers handle type hierarchies and data decomposition in modern Java. Therefore, adopt sealed types and record patterns deliberately — where the type set is stable and operations multiply — to write more expressive, exhaustively checked, and safer code.