Available Hire Me
← All Writing Java

Switch Expressions and Pattern Matching — Java's Best New Feature?

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.

Switch expressions — the foundation

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";
};

Pattern matching on types

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.

Sealed types — exhaustive dispatch without default

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 — filtering within a case arm

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.

Record patterns — destructuring in the case arm

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.

Null handling in switch

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";
    };
}

Replacing the visitor pattern

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;
    };
}

ProTips

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.