Java Pattern Matching: Switch Patterns, Record Patterns, and Sealed Classes
Java’s type system has been evolving rapidly, and Java pattern matching is the most impactful change since generics. Pattern matching eliminates the boilerplate of instanceof checks, type casts, and complex conditional logic. Therefore, this guide covers the practical patterns you’ll use daily — switch patterns, record patterns, sealed class hierarchies, and how they combine to make your code shorter, safer, and more readable.
From instanceof Chains to Pattern Matching
Before pattern matching, checking object types required verbose instanceof checks followed by explicit casts. Every cast was a potential ClassCastException if the logic was wrong, and the compiler couldn’t verify that you handled all cases. Moreover, adding a new type to a hierarchy required searching the codebase for every instanceof check — miss one, and you have a runtime bug.
// Before: Verbose, error-prone instanceof chains
public String describe(Object shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape; // Redundant cast
return "Circle with radius " + c.radius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape; // Another redundant cast
return "Rectangle " + r.width() + "x" + r.height();
} else if (shape instanceof Triangle) {
Triangle t = (Triangle) shape;
return "Triangle with base " + t.base();
} else {
return "Unknown shape"; // What if we add a new shape?
}
}
// After: Pattern matching with instanceof
public String describe(Object shape) {
if (shape instanceof Circle c) { // Check + bind in one step
return "Circle with radius " + c.radius();
} else if (shape instanceof Rectangle r) {
return "Rectangle " + r.width() + "x" + r.height();
} else if (shape instanceof Triangle t) {
return "Triangle with base " + t.base();
} else {
return "Unknown shape";
}
}
// Best: Switch pattern matching (exhaustive, concise)
public String describe(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 base " + t.base();
// Compiler error if you miss a case (with sealed classes)
};
}Switch Patterns: The Power of Exhaustive Matching
Pattern matching switch expressions are more powerful than simple type checks. You can add guard conditions (when clauses), match against null, nest patterns, and combine with deconstruction. Furthermore, when used with sealed types, the compiler verifies you’ve handled every possible case — no default branch needed, and adding a new type produces compile errors everywhere it needs handling.
// Guard conditions with 'when' clause
public String evaluateDiscount(Customer customer) {
return switch (customer) {
case PremiumCustomer p when p.yearsActive() > 10
-> "30% loyalty discount";
case PremiumCustomer p when p.totalSpend() > 50000
-> "25% VIP discount";
case PremiumCustomer p
-> "15% premium discount";
case RegularCustomer r when r.hasReferral()
-> "10% referral discount";
case RegularCustomer r
-> "5% standard discount";
case TrialCustomer t
-> "No discount — trial period";
};
}
// Null handling in switch
public String processInput(Object input) {
return switch (input) {
case null -> "No input provided";
case String s -> "Text: " + s;
case Integer i -> "Number: " + i;
case List<?> l -> "List with " + l.size() + " items";
default -> "Unknown type: " + input.getClass().getSimpleName();
};
}Record Patterns: Deconstructing Data
Records and pattern matching are designed to work together. Record patterns let you deconstruct a record into its components directly in the pattern, eliminating the need to call accessor methods. This is especially powerful with nested records — you can deconstruct multiple levels in a single pattern.
// Records — immutable data carriers
record Point(double x, double y) {}
record Line(Point start, Point end) {}
record Circle(Point center, double radius) {}
// Record patterns — deconstruct in the match
public String describeShape(Object shape) {
return switch (shape) {
// Deconstruct Circle into center point and radius
case Circle(Point(var cx, var cy), var r)
-> String.format("Circle at (%.1f, %.1f) with radius %.1f", cx, cy, r);
// Deconstruct Line into start and end points
case Line(Point(var x1, var y1), Point(var x2, var y2))
-> String.format("Line from (%.1f, %.1f) to (%.1f, %.1f)", x1, y1, x2, y2);
// Nested deconstruction with guard
case Circle(Point(var cx, var cy), var r) when r > 100
-> "Large circle at (" + cx + ", " + cy + ")";
default -> "Unknown shape";
};
}
// Practical example: processing API responses
sealed interface ApiResponse permits Success, ClientError, ServerError {}
record Success(int status, String body) implements ApiResponse {}
record ClientError(int status, String message) implements ApiResponse {}
record ServerError(int status, String message, Throwable cause) implements ApiResponse {}
public void handleResponse(ApiResponse response) {
switch (response) {
case Success(var status, var body)
-> processSuccess(body);
case ClientError(var status, var msg) when status == 404
-> handleNotFound(msg);
case ClientError(var status, var msg) when status == 429
-> handleRateLimit(msg);
case ClientError(var status, var msg)
-> handleClientError(status, msg);
case ServerError(var status, var msg, var cause)
-> handleServerError(status, msg, cause);
}
}Sealed Classes: Complete Type Hierarchies
Sealed classes restrict which classes can extend a type, creating a closed hierarchy that the compiler can reason about. When you combine sealed classes with switch patterns, the compiler guarantees exhaustive matching — every possible subtype is handled. Additionally, adding a new subtype to a sealed hierarchy produces compile errors at every switch that doesn’t handle it, making it impossible to forget a case.
// Sealed hierarchy — compiler knows ALL possible subtypes
public sealed interface PaymentMethod
permits CreditCard, BankTransfer, DigitalWallet, Crypto {}
record CreditCard(String number, String expiry, String cvv) implements PaymentMethod {}
record BankTransfer(String iban, String bic) implements PaymentMethod {}
record DigitalWallet(String provider, String accountId) implements PaymentMethod {}
record Crypto(String walletAddress, String currency) implements PaymentMethod {}
// Process payment — compiler ensures ALL types are handled
public PaymentResult processPayment(PaymentMethod method, Money amount) {
return switch (method) {
case CreditCard(var number, var expiry, var cvv) -> {
validateCard(number, expiry);
yield chargeCard(number, amount);
}
case BankTransfer(var iban, var bic) -> {
validateIBAN(iban);
yield initiateBankTransfer(iban, bic, amount);
}
case DigitalWallet(var provider, var accountId) -> {
yield chargeWallet(provider, accountId, amount);
}
case Crypto(var wallet, var currency) -> {
yield sendCrypto(wallet, currency, amount);
}
// No default needed — compiler verified all cases
// Adding a new PaymentMethod type → compile error here
};
}Practical Patterns: Replacing Visitor and Strategy
Pattern matching with sealed types replaces several classic design patterns. The Visitor pattern — with its double dispatch and accept/visit ceremony — becomes a simple switch expression. Strategy pattern hierarchies become sealed interfaces with record implementations. Furthermore, complex validation logic that used to require chains of validators becomes a single pattern-matching expression.
// Before: Visitor pattern (verbose)
interface ShapeVisitor<T> {
T visitCircle(Circle c);
T visitRectangle(Rectangle r);
T visitTriangle(Triangle t);
}
class AreaCalculator implements ShapeVisitor<Double> {
public Double visitCircle(Circle c) { return Math.PI * c.radius() * c.radius(); }
public Double visitRectangle(Rectangle r) { return r.width() * r.height(); }
public Double visitTriangle(Triangle t) { return 0.5 * t.base() * t.height(); }
}
// After: Pattern matching (concise, readable)
public double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
};
}Related Reading:
Resources:
In conclusion, Java pattern matching transforms how you write conditional logic. Switch patterns replace verbose instanceof chains, record patterns deconstruct data elegantly, and sealed classes provide compile-time exhaustiveness checking. Together, they make Java code shorter, safer, and more expressive — start using them in every new codebase.