Pavan Rangani

HomeBlogJava 23 Pattern Matching: Rewriting Legacy Code with Modern Syntax

Java 23 Pattern Matching: Rewriting Legacy Code with Modern Syntax

By Pavan Rangani · February 11, 2026 · Java & Spring

Java 23 Pattern Matching: Rewriting Legacy Code with Modern Syntax

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. Just as importantly, it explains the edge cases and trade-offs that the introductory tutorials tend to skip.

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

Notice that the bound variable — the c in instanceof Circle c — is scoped only to the branch where the test succeeds. This flow scoping is what makes the feature safe: the compiler will refuse to let you use c in the else branch, because there it provably is not a Circle. As a result, an entire category of “cast the wrong thing” bugs simply disappears.

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

Ordering, Dominance, and the null Edge Case

Pattern switches are order-sensitive in a way that ordinary switches are not, and this trips up newcomers. Labels are tested top to bottom, so a more specific guarded case must appear before its unguarded counterpart. Put case PremiumCustomer p above case PremiumCustomer p when p.totalSpend() > 50000 and the guarded branch becomes unreachable. Fortunately, the compiler enforces this: it raises a “label is dominated by a preceding label” error rather than letting the bug slip into production.

The other surprise is null. Historically, a switch on a null selector threw NullPointerException before any case ran. With pattern switches that behavior is preserved unless you add an explicit case null. This is a deliberate compatibility choice, but it means you must decide consciously how null is handled — silently inheriting an NPE, or routing it to a meaningful branch. A common production pattern is to combine null with the default, as in case null, default -> ..., so unexpected input degrades gracefully instead of throwing.

// Combine null with default for graceful degradation
String label = switch (status) {
    case ACTIVE      -> "running";
    case TERMINATED  -> "stopped";
    case null, default -> "unknown";   // null no longer throws NPE here
};
Java pattern matching code example
Switch patterns with guard conditions replace complex if-else chains with readable, exhaustive matching

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

A subtle but important detail: in the guarded Circle case above, the deconstructed r > 100 branch must be ordered carefully relative to the unguarded one, and the compiler treats record patterns as null-aware. A record pattern like Circle(Point(...), var r) only matches a non-null Circle whose component is itself a non-null Point; a null at any level falls through rather than binding. This makes deeply nested deconstruction safe to write without manual null guards at every level.

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

The “no default needed” property is more valuable than it first appears. A defensive default branch silently absorbs new subtypes, so a feature added in one file can leave a dozen switches quietly mishandling it. Omitting the default turns those into compile errors that point you to exactly the code that needs updating. In practice, teams treat the absence of a default on a sealed switch as a deliberate safety feature, not an oversight.

Sealed classes type hierarchy
Sealed classes create closed type hierarchies with compiler-verified exhaustive matching

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

When NOT to Reach for Pattern Matching: Trade-offs

Pattern matching is a sharp tool, but it is not the answer to every problem. The biggest design tension is that exhaustive switches favor an open set of operations over a closed set of types, while traditional polymorphism favors the opposite. If you frequently add new subtypes but rarely add new operations, classic virtual dispatch — putting the behavior on the type itself — keeps that change in one place. Pattern matching shines when the type set is stable and you keep inventing new things to do with it.

There are practical limits too. Guards make individual cases readable but a switch with twenty deeply nested guarded patterns can become harder to follow than a small set of well-named methods, so extract behavior once a switch grows unwieldy. Pattern matching also does not replace real validation: matching the shape of data tells you nothing about whether the values are valid, so keep your domain rules explicit. Finally, mind your baseline — record patterns and pattern switches require a recent JDK, so confirm your target runtime before sprinkling them across a library that older projects consume. For related deep dives, see Java records and sealed classes for domain modeling and Spring Boot with virtual threads.

Java design patterns with pattern matching
Pattern matching replaces Visitor, Strategy, and complex conditional logic with concise switch expressions

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 — while ordering rules, null handling, and the open-versus-closed trade-off tell you when to reach for it and when not to. Together, these features make Java code shorter, safer, and more expressive, so reach for them in every new codebase where the type set is stable.

← Back to all articles