A deep-dive into switch expressions and pattern matching in Java 21 — arrow syntax, guard patterns, sealed types, and how to replace ad-hoc instanceof chains with exhaustive, type-safe dispatch.
Switch expressions and pattern matching are the most significant improvement to Java’s type system in years. Not because they’re novel — ML-family languages have had them forever — but because they replace a specific class of Java code that has always been verbose, error-prone, and difficult to evolve safely: the instanceof/cast chain and the multi-branch type dispatch.
Java 21 marks the point where both features are fully stable. If you’re writing Java 21 or later and still writing if (x instanceof Foo) { Foo f = (Foo) x; }, you have work to do.
Java 14 made switch an expression (not just a statement). The arrow form eliminates fall-through, which was the original sin of traditional switch:
// Traditional switch statement — fall-through risk
switch (day) {
case MONDAY:
case TUESDAY:
return "Weekday";
case SATURDAY:
case SUNDAY:
return "Weekend";
default:
return "Unknown";
}
// Switch expression — no fall-through, yields a value
String type = switch (day) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};
Two things to notice: case MONDAY, TUESDAY handles multiple values in one arm, and there’s no default — because DayOfWeek is an enum and the switch is exhaustive. If you remove a case, the compiler tells you.
For arms that need more than an expression, use yield:
String label = switch (status) {
case PENDING -> "Awaiting processing";
case ACTIVE -> "Live";
case SUSPENDED -> {
var reason = fetchSuspensionReason(status);
yield "Suspended: " + reason;
}
case CLOSED -> "Closed";
};
Java 16 stabilised instanceof pattern matching. Java 21 brings the same patterns into switch:
// Old style — manual cast after instanceof
Object event = getMessage();
if (event instanceof MarketSuspended) {
MarketSuspended ms = (MarketSuspended) event;
handleSuspension(ms.marketId());
} else if (event instanceof OrderMatched) {
OrderMatched om = (OrderMatched) event;
handleMatch(om.orderId(), om.matchedSize());
} else if (event instanceof PriceChanged) {
PriceChanged pc = (PriceChanged) event;
handlePriceChange(pc.selectionId(), pc.newPrice());
}
With pattern matching in switch:
switch (event) {
case MarketSuspended ms -> handleSuspension(ms.marketId());
case OrderMatched om -> handleMatch(om.orderId(), om.matchedSize());
case PriceChanged pc -> handlePriceChange(pc.selectionId(), pc.newPrice());
default -> log.warn("Unhandled event type: {}", event.getClass().getSimpleName());
}
The binding variable (ms, om, pc) is scoped to the arm — no cast, no intermediate variable, no scope leakage. The switch is an expression if all arms produce a value, a statement if they don’t.
The most powerful combination is sealed interfaces with switch expressions. Sealed types define a closed set of subtypes, which means the compiler can enforce exhaustiveness:
public sealed interface StreamingEvent
permits MarketSuspended, OrderMatched, PriceChanged, MarketClosed {}
public record MarketSuspended(String marketId, Instant suspendedAt) implements StreamingEvent {}
public record OrderMatched(String orderId, BigDecimal matchedSize, double price) implements StreamingEvent {}
public record PriceChanged(long selectionId, double newPrice, double previousPrice) implements StreamingEvent {}
public record MarketClosed(String marketId, String winner) implements StreamingEvent {}
Now the switch over StreamingEvent can be exhaustive — no default required, and if you add a new subtype to the sealed interface, every switch over it becomes a compile error until you handle the new case:
String describe(StreamingEvent event) {
return switch (event) {
case MarketSuspended ms -> "Market " + ms.marketId() + " suspended";
case OrderMatched om -> "Order " + om.orderId() + " matched at " + om.price();
case PriceChanged pc -> "Selection " + pc.selectionId() + " moved to " + pc.newPrice();
case MarketClosed mc -> "Market " + mc.marketId() + " closed, winner: " + mc.winner();
};
}
This is the most important property: the compiler enforces that your dispatch is complete. You can’t add a new event type without visiting every switch that processes them. No runtime IllegalStateException, no silent no-ops for unhandled cases.
Guard patterns add a when clause to a pattern arm:
String categorise(StreamingEvent event) {
return switch (event) {
case PriceChanged pc when pc.newPrice() < pc.previousPrice() -> "price shortening";
case PriceChanged pc when pc.newPrice() > pc.previousPrice() -> "price drifting";
case PriceChanged pc -> "price unchanged";
case OrderMatched om when om.matchedSize().compareTo(new BigDecimal("1000")) > 0 -> "large match";
case OrderMatched om -> "small match";
case MarketSuspended ms -> "suspended";
case MarketClosed mc -> "closed";
};
}
Guard patterns are evaluated in order. The more specific cases (when pc.newPrice() < pc.previousPrice()) must come before the catch-all case PriceChanged pc. The compiler checks this for sealed types.
Java 21 stabilised record patterns, which destructure record components directly in the pattern:
String summarise(StreamingEvent event) {
return switch (event) {
case OrderMatched(String orderId, BigDecimal size, double price) ->
"Matched " + size + " @ " + price + " on order " + orderId;
case PriceChanged(long selectionId, double newPrice, double prev) ->
"Runner " + selectionId + ": " + prev + " -> " + newPrice;
default -> event.toString();
};
}
Record patterns can be nested. For records containing other records:
record MarketUpdate(MarketId id, PriceChanged price) {}
record MarketId(String value) {}
if (update instanceof MarketUpdate(MarketId(String id), PriceChanged(_, double newPrice, _))) {
log.info("Market {} new price {}", id, newPrice);
}
The _ wildcard ignores components you don’t need. Nested record patterns collapse several layers of field access into a single match expression.
Pattern matching switch handles null explicitly — either by adding a case null arm or by letting it throw NullPointerException (the same as a traditional switch). Being explicit is better:
String handle(StreamingEvent event) {
return switch (event) {
case null -> "No event";
case MarketSuspended ms -> "Suspended";
case OrderMatched om -> "Matched";
case PriceChanged pc -> "Price changed";
case MarketClosed mc -> "Closed";
};
}
Sealed types with exhaustive switch largely replace the visitor pattern. The visitor’s purpose is to add operations to a type hierarchy without modifying the types themselves — it achieves this through double dispatch. With sealed types, adding a new operation is simply writing a new function with a switch over the sealed interface. Adding a new subtype remains a compile error until every switch is updated — the same exhaustiveness guarantee that visitor provides, without the boilerplate.
// Visitor: forced double dispatch, accept() on every subtype
// Switch on sealed: no accept(), no Visitor interface, same exhaustiveness
BigDecimal extractVolume(StreamingEvent event) {
return switch (event) {
case OrderMatched om -> om.matchedSize();
case PriceChanged __ -> BigDecimal.ZERO;
case MarketSuspended __, MarketClosed __ -> BigDecimal.ZERO;
};
}
Use sealed interfaces at package or module level: permits lists subtypes, so they can live in the same compilation unit or be spread across a module. Use package-private sealed types for implementation-internal hierarchies; public sealed types for API-facing discriminated unions.
Prefer switches over chains for 3+ cases: For two cases, an if/else is often clearer. For three or more, a switch expression produces code that scales without increasing cognitive load.
Don’t mix old and new: If you’re switching over a sealed type in a new method, use the new exhaustive form. Don’t add a default arm to suppress the exhaustiveness warning — that defeats the purpose.
If you’re working through a Java 17 or 21 migration and want to identify the high-value refactoring opportunities, get in touch.