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.

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

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
    };
}
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();
    };
}
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. Together, they make Java code shorter, safer, and more expressive — start using them in every new codebase.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top