A practical guide to pattern matching in modern Java — instanceof pattern variables, guarded patterns, switch expressions, sealed classes, and record patterns done properly.
Pattern matching is one of those features that looks like minor syntax sugar until you start using it seriously. Then you realise it’s reshaping how you model entire problem domains. I’ve been writing Java for over 20 years, and the combination of sealed classes, switch expressions, and record patterns is the most significant improvement to domain modelling the language has seen since generics. Here’s how to use it properly.
You’ve written this code thousands of times:
// The old way — verbose and error-prone
Object event = getNextEvent();
if (event instanceof OrderCreatedEvent) {
OrderCreatedEvent created = (OrderCreatedEvent) event;
processCreated(created.getOrderId());
} else if (event instanceof OrderCancelledEvent) {
OrderCancelledEvent cancelled = (OrderCancelledEvent) event;
processCancelled(cancelled.getOrderId(), cancelled.getReason());
}
The cast is redundant — you’ve already checked the type. The compiler knows it, you know it, and yet you’re still writing it. Java 16 fixed this.
Java 16 introduced the pattern variable syntax. The instanceof check and the cast collapse into a single expression:
// Java 16+
if (event instanceof OrderCreatedEvent created) {
processCreated(created.getOrderId());
} else if (event instanceof OrderCancelledEvent cancelled) {
processCancelled(cancelled.getOrderId(), cancelled.getReason());
}
created and cancelled are pattern variables — they’re in scope only in the branch where the match succeeded. The compiler enforces this. You cannot accidentally use created in the else branch.
Pattern variables work with boolean guards using &&:
public void handlePayment(Payment payment) {
if (payment instanceof CardPayment card && card.getAmount().compareTo(LIMIT) > 0) {
flagForReview(card);
} else if (payment instanceof CardPayment card) {
process(card);
} else if (payment instanceof BankTransfer transfer && transfer.isInternational()) {
routeToCompliance(transfer);
}
}
The guard card.getAmount().compareTo(LIMIT) > 0 is only evaluated if the instanceof check passes — so card is never null inside the guard. This is the short-circuit safety the old pattern lacked.
Java 14 introduced switch expressions with arrow syntax. Arrow cases eliminate the fall-through footgun entirely:
// Old switch statement — fall-through is a bug waiting to happen
String label;
switch (status) {
case PENDING:
label = "Awaiting";
break; // forget this and you have a bug
case ACTIVE:
label = "Live";
break;
default:
label = "Unknown";
}
// Java 14+ switch expression — no fall-through, no break, yields a value
String label = switch (status) {
case PENDING -> "Awaiting";
case ACTIVE -> "Live";
case CLOSED -> "Closed";
default -> "Unknown";
};
Switch expressions are expressions — they return values, and the compiler enforces that every branch produces one. The yield keyword handles multi-line branches:
String message = switch (errorCode) {
case 404 -> "Not found";
case 500 -> {
log.error("Server error encountered");
yield "Internal server error";
}
default -> "Unexpected error: " + errorCode;
};
Sealed classes (Java 17) declare which classes can extend them. This closed hierarchy is the key that unlocks exhaustive pattern matching:
public sealed interface ApiResponse
permits SuccessResponse, ErrorResponse, RateLimitedResponse {}
public record SuccessResponse(Object data, int statusCode) implements ApiResponse {}
public record ErrorResponse(String message, int statusCode, String errorCode) implements ApiResponse {}
public record RateLimitedResponse(int retryAfterSeconds) implements ApiResponse {}
Now the compiler knows every possible subtype. Switch over a sealed type and you don’t need default — the compiler will tell you if you’ve missed a case:
public String describe(ApiResponse response) {
return switch (response) {
case SuccessResponse s -> "OK (%d)".formatted(s.statusCode());
case ErrorResponse e -> "Error %s: %s".formatted(e.errorCode(), e.message());
case RateLimitedResponse r -> "Rate limited, retry in %ds".formatted(r.retryAfterSeconds());
// No default needed — the compiler verifies exhaustiveness
};
}
Add a new permits type to the sealed interface and every switch over that type fails to compile until you handle the new case. This is exhaustiveness checking — a property that Java previously couldn’t offer without heavy frameworks. If you’ve used Kotlin’s when or Rust’s match, you’ll recognise it immediately.
Java 21 adds record patterns — you can destructure the record’s components directly in the pattern:
public void processEvent(DomainEvent event) {
switch (event) {
case OrderPlaced(var orderId, var customerId, var amount) -> {
log.info("Order {} placed by customer {} for {}", orderId, customerId, amount);
orderService.create(orderId, customerId, amount);
}
case OrderCancelled(var orderId, var reason) -> {
log.warn("Order {} cancelled: {}", orderId, reason);
orderService.cancel(orderId, reason);
}
case PaymentReceived(var orderId, var amount, var reference) -> {
log.info("Payment {} received for order {}", reference, orderId);
paymentService.record(orderId, amount, reference);
}
}
}
The pattern OrderPlaced(var orderId, var customerId, var amount) matches an OrderPlaced record and binds its components in one step. No explicit getters, no cast, no intermediate variable.
You can nest record patterns for nested structures:
case ShipmentCreated(var id, Address(var street, var city, var postcode), var carrier) -> {
dispatch(id, street, city, postcode, carrier);
}
Here’s what this looks like assembled in a practical DWP-style event processing context:
public sealed interface ClaimEvent permits
ClaimSubmitted, ClaimApproved, ClaimRejected, ClaimWithdrawn {}
public record ClaimSubmitted(String claimId, String nino, LocalDate submittedOn) implements ClaimEvent {}
public record ClaimApproved(String claimId, BigDecimal weeklyAmount, LocalDate awardFrom) implements ClaimEvent {}
public record ClaimRejected(String claimId, String reason, boolean rightToAppeal) implements ClaimEvent {}
public record ClaimWithdrawn(String claimId, String withdrawnBy) implements ClaimEvent {}
@Service
public class ClaimEventProcessor {
public ClaimOutcome process(ClaimEvent event) {
return switch (event) {
case ClaimSubmitted(var id, var nino, var date) ->
ClaimOutcome.submitted(id, "Claim %s submitted for %s on %s".formatted(id, nino, date));
case ClaimApproved(var id, var amount, var from) ->
ClaimOutcome.approved(id, amount, from);
case ClaimRejected(var id, var reason, var canAppeal) when canAppeal ->
ClaimOutcome.rejected(id, reason, "Appeal rights available");
case ClaimRejected(var id, var reason, _) ->
ClaimOutcome.rejected(id, reason, "No appeal rights");
case ClaimWithdrawn(var id, var by) ->
ClaimOutcome.withdrawn(id, by);
};
}
}
The guarded pattern case ClaimRejected(...) when canAppeal handles the appeal case explicitly, with the unguarded ClaimRejected case as the fallback. The _ unnamed pattern (Java 21) discards the rightToAppeal value we don’t need in the second branch. The compiler verifies all five cases are covered with no default required.
If you’re still writing instanceof + cast chains in new code, stop. The pattern matching alternatives are strictly better in every dimension: less code, better readability, compiler-enforced safety.
The opinionated take: sealed interfaces + records + switch expressions is the correct Java pattern for discriminated unions. Don’t reach for complex inheritance hierarchies, don’t reach for the Visitor pattern, don’t reach for enum with abstract methods. Model your domain events, API responses, and result types as sealed hierarchies of records, and switch over them exhaustively.
The Visitor pattern in particular becomes entirely redundant once you have sealed types and exhaustive switches. If your codebase still uses Visitor for dispatch over a type hierarchy, that’s a migration worth planning.
permits reference types in different packages, so you’re not forced to co-locate everything._ unnamed pattern (Java 21) discards components you don’t need — use it instead of var ignored for clarity.when in switch, && in if. The when keyword is switch-specific.If you’re modernising a Java codebase and want experienced hands on the migration, get in touch.