Java 21 Pattern Matching for switch

1. Introduction

The switch statement has been part of Java since version 1.0, but for most of its life it was limited to matching exact values — integers, enums, and eventually strings. If you needed to branch based on the type of an object, you were stuck with if-else instanceof chains. That changed dramatically with Java 21.

Here is the journey that got us here:

Java Version Feature JEP What It Added
Java 14 Switch Expressions JEP 361 Switch can return a value with arrow syntax and yield
Java 16 Pattern Matching for instanceof JEP 394 Combine type check and cast in one expression
Java 17 Sealed Classes JEP 409 Restrict which classes can extend a type
Java 21 Pattern Matching for switch JEP 441 Use type patterns, guarded patterns, and null handling in switch
Java 21 Record Patterns JEP 440 Deconstruct records directly in patterns

Each feature built on the previous one. Switch expressions gave us a cleaner syntax. Pattern matching for instanceof let us avoid redundant casts. Sealed classes gave the compiler a closed set of subtypes to reason about. And now, Pattern Matching for switch in Java 21 (JEP 441) brings all of these together into a single, powerful construct.

Think of it like building a house. Switch expressions laid the foundation. Sealed classes put up the walls. Pattern matching for instanceof was the wiring. Java 21 pattern matching for switch is the roof that ties everything together into a complete, weatherproof structure.

Before Java 21, if you wanted to process an object differently based on its runtime type, you wrote code like this:

// Pre-Java 21: the instanceof chain everybody wrote
public String describe(Object obj) {
    if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof Long l) {
        return "Long: " + l;
    } else if (obj instanceof Double d) {
        return "Double: " + d;
    } else if (obj instanceof String s) {
        return "String of length " + s.length() + ": " + s;
    } else if (obj instanceof List list) {
        return "List with " + list.size() + " elements";
    } else if (obj == null) {
        return "null value";
    } else {
        return "Unknown: " + obj.getClass().getSimpleName();
    }
}

That works, but it has problems. Every branch is an if-else, so the compiler cannot verify exhaustiveness. You have to handle null separately before or inside the chain. And the structure — “match this object against a series of patterns and return a result” — is exactly what switch was designed for, yet you could not use it.

Java 21 changes that. Here is the same logic with pattern matching for switch:

// Java 21: pattern matching for switch
public String describe(Object obj) {
    return switch (obj) {
        case Integer i  -> "Integer: " + i;
        case Long l     -> "Long: " + l;
        case Double d   -> "Double: " + d;
        case String s   -> "String of length " + s.length() + ": " + s;
        case List list -> "List with " + list.size() + " elements";
        case null       -> "null value";
        default         -> "Unknown: " + obj.getClass().getSimpleName();
    };
}

Same logic, but cleaner, safer, and more expressive. The switch expression communicates intent: “I am dispatching on the type and value of this object.” Let us explore every feature that makes this possible.

2. Type Patterns in Switch

The most fundamental feature of pattern matching for switch is type patterns. A type pattern checks whether the switch selector matches a particular type and, if it does, binds it to a pattern variable — all in one step. No explicit cast. No separate variable declaration.

The syntax is case Type variableName ->. When the selector matches the type, the variable is automatically cast and available in that case branch.

// Type patterns in switch -- the basics
public double calculateArea(Object 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();
        default          -> throw new IllegalArgumentException("Unknown shape: " + shape);
    };
}

// Compare to the old if-else instanceof chain
public double calculateAreaOld(Object shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.radius() * c.radius();
    } else if (shape instanceof Rectangle r) {
        return r.width() * r.height();
    } else if (shape instanceof Triangle t) {
        return 0.5 * t.base() * t.height();
    } else {
        throw new IllegalArgumentException("Unknown shape: " + shape);
    }
}

Both versions do the same thing, but the switch version has several advantages:

  • Expression form — The switch returns a value directly. No need for a local variable or multiple return statements.
  • Exhaustiveness — If you use a sealed type (covered in section 5), the compiler can verify you handled every case.
  • Clarity of intent — A switch says “I am dispatching.” An if-else chain says “I am checking conditions.” Dispatching on type is conceptually a switch, so the switch form matches how developers think about the problem.

Working with Multiple Types

Type patterns work with any reference type — classes, interfaces, arrays, even generic types with some limitations.

public String processValue(Object value) {
    return switch (value) {
        case Integer i    -> "int: " + i;
        case Long l       -> "long: " + l;
        case Float f      -> "float: " + f;
        case Double d     -> "double: " + d;
        case Boolean b    -> "boolean: " + b;
        case Character c  -> "char: '" + c + "'";
        case String s     -> "string: \"" + s + "\"";
        case int[] arr    -> "int array of length " + arr.length;
        case Object[] arr -> "object array of length " + arr.length;
        default           -> "other: " + value.getClass().getName();
    };
}

// Usage
System.out.println(processValue(42));          // int: 42
System.out.println(processValue("hello"));     // string: "hello"
System.out.println(processValue(new int[]{1,2,3})); // int array of length 3

Type Patterns Replace Visitor Pattern

For years, Java developers used the Visitor pattern to dispatch on types without instanceof chains. Type patterns in switch offer a simpler alternative for many cases where the Visitor pattern was historically used.

// Before: Visitor pattern (lots of boilerplate)
interface ShapeVisitor {
    T visitCircle(Circle c);
    T visitRectangle(Rectangle r);
    T visitTriangle(Triangle t);
}

interface Shape {
     T accept(ShapeVisitor visitor);
}

class Circle implements Shape {
    double radius;
    public  T accept(ShapeVisitor visitor) { return visitor.visitCircle(this); }
}

class Rectangle implements Shape {
    double width, height;
    public  T accept(ShapeVisitor visitor) { return visitor.visitRectangle(this); }
}

// After: Type pattern in switch (minimal boilerplate)
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;
    };
}

The Visitor pattern still has its place — especially when you need double dispatch or when the operations need to be defined in separate classes. But for simple type-based dispatching, pattern matching for switch is dramatically simpler.

3. Guarded Patterns

Sometimes matching on type alone is not enough. You need to match on type and a condition about the value. Java 21 introduces guarded patterns using the when keyword. A guarded pattern is a type pattern followed by when and a boolean expression.

The syntax is: case Type variable when condition ->

Think of it like a bouncer at a club. The type pattern checks your ID (are you the right type?). The when clause checks the guest list (do you meet the additional criteria?).

// Guarded patterns with the 'when' clause
public String classifyString(Object obj) {
    return switch (obj) {
        case String s when s.isEmpty()       -> "empty string";
        case String s when s.length() <= 5   -> "short string: " + s;
        case String s when s.length() <= 20  -> "medium string: " + s;
        case String s                        -> "long string: " + s.substring(0, 20) + "...";
        default                              -> "not a string";
    };
}

// Guarded patterns with numeric ranges
public String classifyNumber(Object obj) {
    return switch (obj) {
        case Integer i when i < 0    -> "negative integer: " + i;
        case Integer i when i == 0   -> "zero";
        case Integer i when i <= 100 -> "small positive integer: " + i;
        case Integer i               -> "large positive integer: " + i;
        case Double d when d.isNaN() -> "NaN";
        case Double d when d.isInfinite() -> "infinite";
        case Double d                -> "double: " + d;
        default                      -> "not a number";
    };
}

Important: The order of guarded cases matters. Java evaluates cases from top to bottom and takes the first match. If you put case String s (unguarded) before case String s when s.isEmpty(), the unguarded pattern would match every string, and the guarded one would never execute. The compiler will flag this as an error — the guarded pattern is dominated by the unguarded one.

Complex Guard Conditions

The when clause accepts any boolean expression. You can call methods, use logical operators, and reference the pattern variable.

// Complex conditions in when clauses
public String analyzeCollection(Object obj) {
    return switch (obj) {
        case List list when list.isEmpty()
            -> "empty list";
        case List list when list.size() == 1
            -> "singleton list: " + list.get(0);
        case List list when list.size() > 1000
            -> "large list with " + list.size() + " elements";
        case List list when list.stream().allMatch(e -> e instanceof String)
            -> "string list with " + list.size() + " elements";
        case List list
            -> "mixed list with " + list.size() + " elements";
        case Set set when set.isEmpty()
            -> "empty set";
        case Set set
            -> "set with " + set.size() + " elements";
        case Map map when map.containsKey("id")
            -> "map with id: " + map.get("id");
        case Map map
            -> "map with " + map.size() + " entries";
        default
            -> "unknown collection type";
    };
}

// Guard with method calls
record Employee(String name, String department, int salary) {}

public String evaluateEmployee(Object obj) {
    return switch (obj) {
        case Employee e when e.department().equals("Engineering") && e.salary() > 150000
            -> e.name() + " is a senior engineer";
        case Employee e when e.department().equals("Engineering")
            -> e.name() + " is an engineer";
        case Employee e when e.salary() > 200000
            -> e.name() + " is a high earner in " + e.department();
        case Employee e
            -> e.name() + " works in " + e.department();
        default
            -> "not an employee";
    };
}

Guarded patterns give you the power of if-else conditions with the structure and clarity of switch. They make it possible to express complex dispatching logic that would previously require nested if-else chains inside switch cases.

4. Null Handling in Switch

Before Java 21, passing null to a switch statement threw a NullPointerException. Always. No exceptions (well, one exception — the NPE itself). This meant you always had to add a null check before the switch, which was easy to forget and led to runtime crashes.

Java 21 lets you handle null directly as a case label. No more defensive null checks before the switch. No more surprise NPEs.

// Before Java 21: null check required before switch
public String formatValueOld(Object value) {
    if (value == null) {
        return "no value";
    }
    return switch (value) {
        case Integer i -> String.format("%,d", i);
        case Double d  -> String.format("%.2f", d);
        case String s  -> "\"" + s + "\"";
        default        -> value.toString();
    };
}

// Java 21: null is just another case
public String formatValue(Object value) {
    return switch (value) {
        case null      -> "no value";
        case Integer i -> String.format("%,d", i);
        case Double d  -> String.format("%.2f", d);
        case String s  -> "\"" + s + "\"";
        default        -> value.toString();
    };
}

Combining null with default

You can combine null and default into a single case if you want them to behave the same way. This is useful when null and unknown types should be handled identically.

// null combined with default
public String processInput(Object input) {
    return switch (input) {
        case String s  -> "text: " + s;
        case Integer i -> "number: " + i;
        case null, default -> "unsupported input";
    };
}

// null as its own case with specific behavior
public String handleRequest(Object request) {
    return switch (request) {
        case null -> {
            logWarning("Received null request");
            yield "error: null request";
        }
        case HttpRequest http -> processHttp(http);
        case GrpcRequest grpc -> processGrpc(grpc);
        default -> {
            logWarning("Unknown request type: " + request.getClass());
            yield "error: unsupported request type";
        }
    };
}

Key rules for null in switch:

  • case null can appear anywhere in the switch, but conventionally it is placed first or last.
  • If no case null is present and the selector is null, a NullPointerException is thrown — same behavior as before Java 21.
  • case null can be combined with default (case null, default) but not with type patterns.
  • With case null, you no longer need a separate null check before the switch. The switch itself handles it.

5. Sealed Classes with Pattern Matching

This is where pattern matching for switch truly shines. When you use a sealed class or sealed interface as the selector type, the compiler knows every possible subtype. This means the compiler can verify that your switch covers all cases — no default branch needed. If you add a new subtype to the sealed hierarchy, every switch that does not handle it will fail to compile. This is exhaustiveness checking, and it is a game-changer for maintainability.

Think of it like a multiple-choice exam where the teacher gives you exactly four options: A, B, C, D. If you answer all four, you are covered. If the teacher adds option E next semester, your answer key automatically flags the missing answer.

// Sealed interface with exhaustive switch
public sealed interface PaymentMethod permits
        CreditCard, DebitCard, BankTransfer, DigitalWallet {}

public record CreditCard(String number, String expiry, int cvv) implements PaymentMethod {}
public record DebitCard(String number, String pin) implements PaymentMethod {}
public record BankTransfer(String routingNumber, String accountNumber) implements PaymentMethod {}
public record DigitalWallet(String provider, String email) implements PaymentMethod {}

// Exhaustive switch -- no default needed!
public String processPayment(PaymentMethod method, double amount) {
    return switch (method) {
        case CreditCard cc     -> chargeCreditCard(cc, amount);
        case DebitCard dc      -> chargeDebitCard(dc, amount);
        case BankTransfer bt   -> initiateBankTransfer(bt, amount);
        case DigitalWallet dw  -> chargeDigitalWallet(dw, amount);
        // No default needed -- compiler knows these are ALL the types
    };
}

// If you later add: public record Cryptocurrency(...) implements PaymentMethod {}
// The compiler will immediately flag processPayment() as non-exhaustive.
// You MUST add a case for Cryptocurrency before the code compiles.

Sealed Hierarchies with Multiple Levels

Sealed hierarchies can be nested. A sealed class can have sealed subclasses, each with their own permitted subtypes. Pattern matching handles this naturally.

// Multi-level sealed hierarchy
public sealed interface Expr permits Literal, BinaryOp, UnaryOp {}

public record Literal(double value) implements Expr {}

public sealed interface BinaryOp extends Expr permits Add, Subtract, Multiply, Divide {}
public record Add(Expr left, Expr right) implements BinaryOp {}
public record Subtract(Expr left, Expr right) implements BinaryOp {}
public record Multiply(Expr left, Expr right) implements BinaryOp {}
public record Divide(Expr left, Expr right) implements BinaryOp {}

public sealed interface UnaryOp extends Expr permits Negate, Abs {}
public record Negate(Expr operand) implements UnaryOp {}
public record Abs(Expr operand) implements UnaryOp {}

// Exhaustive evaluation
public double evaluate(Expr expr) {
    return switch (expr) {
        case Literal(var value)       -> value;
        case Add(var l, var r)        -> evaluate(l) + evaluate(r);
        case Subtract(var l, var r)   -> evaluate(l) - evaluate(r);
        case Multiply(var l, var r)   -> evaluate(l) * evaluate(r);
        case Divide(var l, var r)     -> {
            double divisor = evaluate(r);
            if (divisor == 0) throw new ArithmeticException("Division by zero");
            yield evaluate(l) / divisor;
        }
        case Negate(var operand)      -> -evaluate(operand);
        case Abs(var operand)         -> Math.abs(evaluate(operand));
    };
    // No default needed -- all types covered
}

The compiler checks exhaustiveness across the entire hierarchy. If you add a Modulo operation to BinaryOp, every switch over Expr will fail to compile until you handle it. This compile-time safety is one of the strongest arguments for combining sealed types with pattern matching.

Without Sealed + Pattern Matching With Sealed + Pattern Matching
New subtype added silently Compiler error at every incomplete switch
Default branch hides missing cases No default needed — compiler verifies completeness
Runtime errors when unknown type encountered Compile-time guarantee of exhaustiveness
Must manually search codebase for all dispatches Compiler finds them all for you

6. Dominance and Ordering

When you have multiple type patterns in a switch, the order matters. Java 21 enforces dominance rules: a more general pattern cannot appear before a more specific one, because the general pattern would match everything the specific one matches, making the specific one unreachable.

Think of it like sorting mail. If you have a slot labeled “All Mail” before a slot labeled “Letters from Mom,” nothing will ever reach the Mom slot. The compiler catches this mistake.

// COMPILE ERROR: dominated pattern
public String badOrdering(Object obj) {
    return switch (obj) {
        case Object o   -> "object";     // This matches EVERYTHING
        case String s   -> "string";     // UNREACHABLE -- dominated by Object
        case Integer i  -> "integer";    // UNREACHABLE -- dominated by Object
    };
    // Compiler error: "this case label is dominated by a preceding case label"
}

// CORRECT: specific types before general types
public String goodOrdering(Object obj) {
    return switch (obj) {
        case String s   -> "string";     // Most specific first
        case Integer i  -> "integer";    // Also specific
        case Number n   -> "number";     // More general than Integer, but after it
        case Object o   -> "object";     // Most general last
    };
}

// Dominance also applies to guarded vs unguarded patterns of the same type
public String guardOrdering(Object obj) {
    return switch (obj) {
        // CORRECT: guarded patterns before unguarded of same type
        case String s when s.isEmpty() -> "empty string";
        case String s when s.length() < 10 -> "short string";
        case String s -> "long string";   // Catches all remaining strings
        default -> "not a string";
    };
}

// COMPILE ERROR: unguarded before guarded of same type
public String badGuardOrdering(Object obj) {
    return switch (obj) {
        case String s -> "any string";           // Matches ALL strings
        case String s when s.isEmpty() -> "empty"; // UNREACHABLE -- dominated
        default -> "not a string";
    };
}

Dominance Rules Summary

Rule Example Compiler Behavior
Supertype dominates subtype case Object before case String Compile error
Unguarded dominates guarded (same type) case String s before case String s when ... Compile error
Guarded does NOT dominate unguarded case String s when ... before case String s OK — unguarded catches remaining
Unrelated types case String s before case Integer i OK — no dominance relationship
default dominates everything default before case String s Compile error

The compiler enforces these rules to prevent dead code. If you see a “dominated pattern” error, move the more specific pattern above the more general one.

7. Parenthesized Patterns

Java 21 supports parenthesized patterns, which allow you to group pattern expressions for clarity. While simple pattern matching does not require parentheses, they become useful when combining patterns with guard conditions that involve complex boolean logic.

Parenthesized patterns help disambiguate how the when clause binds to the pattern, making your intent clear to both the compiler and other developers reading your code.

// Parenthesized patterns for clarity
public String classifyValue(Object obj) {
    return switch (obj) {
        case (String s) when s.contains("@") && s.contains(".")
            -> "email-like string: " + s;
        case (String s) when s.matches("\\d{3}-\\d{3}-\\d{4}")
            -> "phone number: " + s;
        case (String s) when s.startsWith("http://") || s.startsWith("https://")
            -> "URL: " + s;
        case (String s)
            -> "plain string: " + s;
        case (Integer i) when i >= 0 && i <= 255
            -> "byte-range integer: " + i;
        case (Integer i)
            -> "integer: " + i;
        default
            -> "other: " + obj;
    };
}

// Without parentheses, the same code works -- parentheses are optional
// but they can improve readability for complex guard conditions
public String withoutParens(Object obj) {
    return switch (obj) {
        case String s when s.contains("@") && s.contains(".")
            -> "email-like string: " + s;
        case String s
            -> "plain string: " + s;
        default
            -> "other: " + obj;
    };
}

Parenthesized patterns are primarily a readability tool. Use them when your guard conditions are complex enough that grouping the pattern visually separates it from the condition, making the code easier to scan.

8. Switch with Records

Records and pattern matching for switch are natural partners. A record pattern lets you deconstruct a record directly in a switch case, extracting its components into variables in one step. This is one of the most powerful features introduced in Java 21 (JEP 440, finalized alongside JEP 441).

Think of it like opening a package. Instead of receiving the box (the record) and then opening it to get the contents (calling accessor methods), record patterns let you describe what is inside the box and have the contents placed directly into your hands.

// Basic record pattern in switch
record Point(int x, int y) {}

public String describePoint(Object obj) {
    return switch (obj) {
        case Point(int x, int y) when x == 0 && y == 0 -> "origin";
        case Point(int x, int y) when x == 0            -> "on y-axis at y=" + y;
        case Point(int x, int y) when y == 0            -> "on x-axis at x=" + x;
        case Point(int x, int y)                        -> "point at (" + x + ", " + y + ")";
        default -> "not a point";
    };
}

// Record pattern with var
public String describePointVar(Object obj) {
    return switch (obj) {
        case Point(var x, var y) when x == 0 && y == 0 -> "origin";
        case Point(var x, var y) -> "point at (" + x + ", " + y + ")";
        default -> "not a point";
    };
}

Nested Record Patterns

The real power comes with nested deconstruction. If a record contains other records as components, you can deconstruct them all in a single pattern.

// Nested record deconstruction
record Point(int x, int y) {}
record Line(Point start, Point end) {}
record Circle(Point center, double radius) {}
record Rectangle(Point topLeft, Point bottomRight) {}

public String describeShape(Object obj) {
    return switch (obj) {
        // Nested deconstruction -- extract points AND their coordinates
        case Line(Point(var x1, var y1), Point(var x2, var y2))
            -> String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);

        case Circle(Point(var cx, var cy), var r)
            -> String.format("Circle at (%d,%d) with radius %.1f", cx, cy, r);

        case Rectangle(Point(var x1, var y1), Point(var x2, var y2))
            -> String.format("Rectangle from (%d,%d) to (%d,%d)", x1, y1, x2, y2);

        default -> "unknown shape";
    };
}

// Compare to the old way
public String describeShapeOld(Object obj) {
    if (obj instanceof Line line) {
        Point start = line.start();
        Point end = line.end();
        int x1 = start.x();
        int y1 = start.y();
        int x2 = end.x();
        int y2 = end.y();
        return String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
    }
    // ... and so on for each type
    return "unknown shape";
}

The nested pattern extracts all four coordinate values in a single line. The old way required six lines of accessor calls just for the Line type. Across multiple types in a switch, the savings are enormous.

Records with Guard Clauses

// Records with guard clauses in switch
record HttpResponse(int statusCode, String body) {}

public String handleResponse(Object obj) {
    return switch (obj) {
        case HttpResponse(var code, var body) when code >= 200 && code < 300
            -> "Success: " + body;
        case HttpResponse(var code, var body) when code == 301 || code == 302
            -> "Redirect: " + body;
        case HttpResponse(var code, var body) when code == 404
            -> "Not Found";
        case HttpResponse(var code, var body) when code >= 500
            -> "Server Error: " + body;
        case HttpResponse(var code, var body)
            -> "HTTP " + code + ": " + body;
        default -> "not an HTTP response";
    };
}

// Deeply nested records with guards
record Address(String city, String state, String zip) {}
record Customer(String name, Address address) {}

public double calculateShipping(Object obj) {
    return switch (obj) {
        case Customer(var name, Address(var city, var state, var zip))
                when state.equals("CA") || state.equals("OR") || state.equals("WA")
            -> 5.99;
        case Customer(var name, Address(var city, var state, var zip))
                when state.equals("NY") || state.equals("NJ")
            -> 8.99;
        case Customer(var name, Address(var city, var state, var zip))
            -> 12.99;
        default -> throw new IllegalArgumentException("Not a customer");
    };
}

Record patterns with guard clauses let you express complex routing logic in a clean, declarative style. The switch reads almost like a decision table: for each combination of type and condition, here is what to do.

9. Practical Patterns

Let us look at real-world scenarios where pattern matching for switch transforms the code you write every day. These are not toy examples — they are patterns you will find in production codebases dealing with events, commands, API responses, and business rules.

Type-Safe Event Handling

// Type-safe event system with sealed types and pattern matching
public sealed interface DomainEvent permits
        UserCreated, UserUpdated, UserDeleted,
        OrderPlaced, OrderShipped, OrderDelivered {}

public record UserCreated(String userId, String email, Instant createdAt)
        implements DomainEvent {}
public record UserUpdated(String userId, Map changes, Instant updatedAt)
        implements DomainEvent {}
public record UserDeleted(String userId, String reason, Instant deletedAt)
        implements DomainEvent {}
public record OrderPlaced(String orderId, String userId, BigDecimal total, Instant placedAt)
        implements DomainEvent {}
public record OrderShipped(String orderId, String trackingNumber, Instant shippedAt)
        implements DomainEvent {}
public record OrderDelivered(String orderId, Instant deliveredAt)
        implements DomainEvent {}

// Event handler -- exhaustive, no default, compiler-verified
public void handleEvent(DomainEvent event) {
    switch (event) {
        case UserCreated(var id, var email, var at) -> {
            sendWelcomeEmail(email);
            createUserProfile(id);
            log.info("User {} created at {}", id, at);
        }
        case UserUpdated(var id, var changes, var at) -> {
            applyChanges(id, changes);
            log.info("User {} updated at {}: {}", id, at, changes.keySet());
        }
        case UserDeleted(var id, var reason, var at) -> {
            archiveUser(id);
            log.info("User {} deleted at {} for: {}", id, at, reason);
        }
        case OrderPlaced(var orderId, var userId, var total, var at) -> {
            reserveInventory(orderId);
            chargeCustomer(userId, total);
            log.info("Order {} placed by {} for ${} at {}", orderId, userId, total, at);
        }
        case OrderShipped(var orderId, var tracking, var at) -> {
            notifyCustomer(orderId, tracking);
            log.info("Order {} shipped with tracking {} at {}", orderId, tracking, at);
        }
        case OrderDelivered(var orderId, var at) -> {
            closeOrder(orderId);
            log.info("Order {} delivered at {}", orderId, at);
        }
    }
}

Command Processing

// Command pattern with pattern matching
public sealed interface Command permits
        CreateUser, UpdateUser, DeleteUser, ResetPassword {}

public record CreateUser(String email, String name) implements Command {}
public record UpdateUser(String userId, String field, String value) implements Command {}
public record DeleteUser(String userId) implements Command {}
public record ResetPassword(String email) implements Command {}

public sealed interface CommandResult permits Success, Failure, Pending {}
public record Success(String message, Object data) implements CommandResult {}
public record Failure(String error, int code) implements CommandResult {}
public record Pending(String taskId) implements CommandResult {}

public CommandResult execute(Command command) {
    return switch (command) {
        case CreateUser(var email, var name) when !email.contains("@")
            -> new Failure("Invalid email", 400);
        case CreateUser(var email, var name) when userExists(email)
            -> new Failure("User already exists", 409);
        case CreateUser(var email, var name)
            -> new Success("User created", createUser(email, name));

        case UpdateUser(var id, var field, var value) when !userExists(id)
            -> new Failure("User not found", 404);
        case UpdateUser(var id, var field, var value)
            -> new Success("User updated", updateField(id, field, value));

        case DeleteUser(var id) when !userExists(id)
            -> new Failure("User not found", 404);
        case DeleteUser(var id)
            -> new Success("User deleted", deleteUser(id));

        case ResetPassword(var email)
            -> new Pending(queuePasswordReset(email));
    };
}

// Then process the result with pattern matching too!
public String toHttpResponse(CommandResult result) {
    return switch (result) {
        case Success(var msg, var data) -> "200 OK: " + msg;
        case Failure(var err, var code) -> code + " Error: " + err;
        case Pending(var taskId)        -> "202 Accepted: task=" + taskId;
    };
}

Response Handling

// API response handling with pattern matching
public sealed interface ApiResponse permits
        ApiResponse.Ok, ApiResponse.Error, ApiResponse.Loading {}

public record Ok(T data, Map headers) implements ApiResponse {}
public record Error(int code, String message, List details) implements ApiResponse {}
public record Loading(double progress) implements ApiResponse {}

// Processing API responses
public  void handleApiResponse(ApiResponse response) {
    switch (response) {
        case Ok(var data, var headers) -> {
            updateUI(data);
            cacheResponse(data, headers);
        }
        case Error(var code, var msg, var details) when code == 401 -> {
            refreshToken();
            retryRequest();
        }
        case Error(var code, var msg, var details) when code == 429 -> {
            int retryAfter = parseRetryAfter(details);
            scheduleRetry(retryAfter);
        }
        case Error(var code, var msg, var details) -> {
            showError(msg);
            logDetails(details);
        }
        case Loading(var progress) when progress > 0.9 -> {
            showAlmostDone();
        }
        case Loading(var progress) -> {
            updateProgressBar(progress);
        }
    }
}

Expression Evaluation

// Mathematical expression evaluator
public sealed interface MathExpr permits
        Num, Var, Add, Mul, Pow, Neg {}

public record Num(double value) implements MathExpr {}
public record Var(String name) implements MathExpr {}
public record Add(MathExpr left, MathExpr right) implements MathExpr {}
public record Mul(MathExpr left, MathExpr right) implements MathExpr {}
public record Pow(MathExpr base, MathExpr exponent) implements MathExpr {}
public record Neg(MathExpr operand) implements MathExpr {}

public double eval(MathExpr expr, Map env) {
    return switch (expr) {
        case Num(var v)          -> v;
        case Var(var name)       -> env.getOrDefault(name, 0.0);
        case Add(var l, var r)   -> eval(l, env) + eval(r, env);
        case Mul(var l, var r)   -> eval(l, env) * eval(r, env);
        case Pow(var b, var e)   -> Math.pow(eval(b, env), eval(e, env));
        case Neg(var operand)    -> -eval(operand, env);
    };
}

// Pretty printer using the same pattern
public String prettyPrint(MathExpr expr) {
    return switch (expr) {
        case Num(var v)         -> String.valueOf(v);
        case Var(var name)      -> name;
        case Add(var l, var r)  -> "(" + prettyPrint(l) + " + " + prettyPrint(r) + ")";
        case Mul(var l, var r)  -> "(" + prettyPrint(l) + " * " + prettyPrint(r) + ")";
        case Pow(var b, var e)  -> prettyPrint(b) + "^" + prettyPrint(e);
        case Neg(var operand)   -> "-" + prettyPrint(operand);
    };
}

// Simplifier that reduces expressions
public MathExpr simplify(MathExpr expr) {
    return switch (expr) {
        case Add(Num(var a), Num(var b))           -> new Num(a + b);
        case Mul(Num(var a), Num(var b))           -> new Num(a * b);
        case Add(var e, Num n) when n.value() == 0 -> simplify(e);
        case Add(Num n, var e) when n.value() == 0 -> simplify(e);
        case Mul(var e, Num n) when n.value() == 1 -> simplify(e);
        case Mul(Num n, var e) when n.value() == 1 -> simplify(e);
        case Mul(var e, Num n) when n.value() == 0 -> new Num(0);
        case Mul(Num n, var e) when n.value() == 0 -> new Num(0);
        case Neg(Neg(var e))                       -> simplify(e);
        case MathExpr e                            -> e;
    };
}

Each of these examples demonstrates a key pattern: define your domain as sealed types (often records), then process them with exhaustive switch expressions. The result is code that is concise, type-safe, and self-documenting.

10. Before vs After

Nothing demonstrates the value of a feature like side-by-side comparisons with real code. Here are six examples of common patterns rewritten from pre-Java 21 style to Java 21 pattern matching for switch.

Example 1: Shape Area Calculation

// BEFORE: instanceof chain with manual casts
public double calculateArea(Object shape) {
    if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return Math.PI * c.getRadius() * c.getRadius();
    } else if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return r.getWidth() * r.getHeight();
    } else if (shape instanceof Triangle) {
        Triangle t = (Triangle) shape;
        return 0.5 * t.getBase() * t.getHeight();
    } else if (shape instanceof Polygon) {
        Polygon p = (Polygon) shape;
        return p.calculateArea();
    } else {
        throw new IllegalArgumentException("Unknown shape: " + shape.getClass());
    }
}

// AFTER: pattern matching switch expression
public double calculateArea(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();
        case Polygon p   -> p.calculateArea();
    };
}

Example 2: JSON Value Formatting

// BEFORE: nested if-else with instanceof
public String formatJson(Object value) {
    if (value == null) {
        return "null";
    } else if (value instanceof String) {
        return "\"" + ((String) value).replace("\"", "\\\"") + "\"";
    } else if (value instanceof Number) {
        return value.toString();
    } else if (value instanceof Boolean) {
        return value.toString();
    } else if (value instanceof List) {
        List list = (List) value;
        return list.stream()
                .map(this::formatJson)
                .collect(Collectors.joining(", ", "[", "]"));
    } else if (value instanceof Map) {
        Map map = (Map) value;
        return map.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\": " + formatJson(e.getValue()))
                .collect(Collectors.joining(", ", "{", "}"));
    } else {
        throw new IllegalArgumentException("Cannot serialize: " + value.getClass());
    }
}

// AFTER: clean switch with null handling
public String formatJson(Object value) {
    return switch (value) {
        case null           -> "null";
        case String s       -> "\"" + s.replace("\"", "\\\"") + "\"";
        case Number n       -> n.toString();
        case Boolean b      -> b.toString();
        case List list   -> list.stream()
                .map(this::formatJson)
                .collect(Collectors.joining(", ", "[", "]"));
        case Map map   -> map.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\": " + formatJson(e.getValue()))
                .collect(Collectors.joining(", ", "{", "}"));
        default -> throw new IllegalArgumentException("Cannot serialize: " + value.getClass());
    };
}

Example 3: Error Response Mapping

// BEFORE: traditional approach with if-else and manual field extraction
public ResponseEntity handleError(Exception ex) {
    if (ex instanceof ValidationException) {
        ValidationException ve = (ValidationException) ex;
        return ResponseEntity.badRequest()
                .body(Map.of("error", ve.getMessage(), "fields", ve.getFieldErrors()));
    } else if (ex instanceof NotFoundException) {
        NotFoundException nfe = (NotFoundException) ex;
        return ResponseEntity.status(404)
                .body(Map.of("error", nfe.getMessage(), "resource", nfe.getResourceType()));
    } else if (ex instanceof UnauthorizedException) {
        return ResponseEntity.status(401)
                .body(Map.of("error", "Unauthorized"));
    } else if (ex instanceof RateLimitException) {
        RateLimitException rle = (RateLimitException) ex;
        return ResponseEntity.status(429)
                .header("Retry-After", String.valueOf(rle.getRetryAfterSeconds()))
                .body(Map.of("error", "Rate limited", "retryAfter", rle.getRetryAfterSeconds()));
    } else {
        return ResponseEntity.status(500)
                .body(Map.of("error", "Internal server error"));
    }
}

// AFTER: pattern matching with sealed exception hierarchy
public ResponseEntity handleError(AppException ex) {
    return switch (ex) {
        case ValidationException(var msg, var fields)
            -> ResponseEntity.badRequest()
                .body(Map.of("error", msg, "fields", fields));
        case NotFoundException(var msg, var resource)
            -> ResponseEntity.status(404)
                .body(Map.of("error", msg, "resource", resource));
        case UnauthorizedException(var msg)
            -> ResponseEntity.status(401)
                .body(Map.of("error", msg));
        case RateLimitException(var msg, var retryAfter)
            -> ResponseEntity.status(429)
                .header("Retry-After", String.valueOf(retryAfter))
                .body(Map.of("error", msg, "retryAfter", retryAfter));
    };
}

Example 4: Database Result Processing

// BEFORE: processing different query result types
public void processResult(Object result) {
    if (result == null) {
        System.out.println("No results found");
    } else if (result instanceof Integer) {
        int count = (Integer) result;
        System.out.println("Affected rows: " + count);
    } else if (result instanceof List) {
        List rows = (List) result;
        if (rows.isEmpty()) {
            System.out.println("Query returned empty result set");
        } else {
            System.out.println("Found " + rows.size() + " rows");
            rows.forEach(System.out::println);
        }
    } else if (result instanceof Map) {
        Map row = (Map) result;
        System.out.println("Single row: " + row);
    } else {
        System.out.println("Unexpected result: " + result.getClass());
    }
}

// AFTER: sealed result type with pattern matching
public sealed interface QueryResult permits
        NoResult, RowCount, SingleRow, MultipleRows {}

public record NoResult() implements QueryResult {}
public record RowCount(int count) implements QueryResult {}
public record SingleRow(Map data) implements QueryResult {}
public record MultipleRows(List> rows) implements QueryResult {}

public void processResult(QueryResult result) {
    switch (result) {
        case NoResult()                  -> System.out.println("No results found");
        case RowCount(var count)         -> System.out.println("Affected rows: " + count);
        case SingleRow(var data)         -> System.out.println("Single row: " + data);
        case MultipleRows(var rows) when rows.isEmpty()
            -> System.out.println("Query returned empty result set");
        case MultipleRows(var rows)      -> {
            System.out.println("Found " + rows.size() + " rows");
            rows.forEach(System.out::println);
        }
    }
}

Example 5: Configuration Value Parsing

// BEFORE: parsing config values with type checking
public Object parseConfigValue(String key, Object rawValue) {
    if (rawValue == null) {
        throw new ConfigException("Missing required config: " + key);
    } else if (rawValue instanceof String) {
        String s = (String) rawValue;
        // Try to parse as number
        try {
            return Integer.parseInt(s);
        } catch (NumberFormatException e1) {
            try {
                return Double.parseDouble(s);
            } catch (NumberFormatException e2) {
                if ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)) {
                    return Boolean.parseBoolean(s);
                }
                return s;
            }
        }
    } else if (rawValue instanceof Number) {
        return rawValue;
    } else if (rawValue instanceof Boolean) {
        return rawValue;
    } else if (rawValue instanceof List) {
        return ((List) rawValue).stream()
                .map(v -> parseConfigValue(key, v))
                .toList();
    } else {
        throw new ConfigException("Unsupported config type for " + key + ": " + rawValue.getClass());
    }
}

// AFTER: pattern matching with guards
public Object parseConfigValue(String key, Object rawValue) {
    return switch (rawValue) {
        case null -> throw new ConfigException("Missing required config: " + key);

        case String s when s.matches("-?\\d+")
            -> Integer.parseInt(s);
        case String s when s.matches("-?\\d+\\.\\d+")
            -> Double.parseDouble(s);
        case String s when s.equalsIgnoreCase("true") || s.equalsIgnoreCase("false")
            -> Boolean.parseBoolean(s);
        case String s
            -> s;

        case Number n  -> n;
        case Boolean b -> b;

        case List list
            -> list.stream().map(v -> parseConfigValue(key, v)).toList();

        default -> throw new ConfigException(
                "Unsupported config type for " + key + ": " + rawValue.getClass());
    };
}

Example 6: Message Routing

// BEFORE: message routing with instanceof chain
public void routeMessage(Object message) {
    if (message == null) {
        log.warn("Received null message");
        return;
    }
    if (message instanceof TextMessage) {
        TextMessage tm = (TextMessage) message;
        if (tm.getText().startsWith("/")) {
            handleCommand(tm);
        } else {
            handleText(tm);
        }
    } else if (message instanceof ImageMessage) {
        ImageMessage im = (ImageMessage) message;
        if (im.getSize() > MAX_IMAGE_SIZE) {
            rejectLargeImage(im);
        } else {
            handleImage(im);
        }
    } else if (message instanceof VideoMessage) {
        handleVideo((VideoMessage) message);
    } else if (message instanceof LocationMessage) {
        handleLocation((LocationMessage) message);
    } else {
        log.warn("Unknown message type: " + message.getClass());
    }
}

// AFTER: pattern matching with guards for sub-conditions
public void routeMessage(Message message) {
    switch (message) {
        case null -> log.warn("Received null message");

        case TextMessage tm when tm.text().startsWith("/")
            -> handleCommand(tm);
        case TextMessage tm
            -> handleText(tm);

        case ImageMessage im when im.size() > MAX_IMAGE_SIZE
            -> rejectLargeImage(im);
        case ImageMessage im
            -> handleImage(im);

        case VideoMessage vm  -> handleVideo(vm);
        case LocationMessage lm -> handleLocation(lm);
    }
}

In every example, the Java 21 version is shorter, clearer, and safer. The switch communicates the dispatching structure. The pattern variables eliminate redundant casts. The guard clauses keep sub-conditions inline rather than nesting more if-else blocks inside each branch.

11. Pattern Matching Comparison Table

Java now offers three primary ways to dispatch on types: instanceof pattern matching, switch pattern matching, and the Visitor pattern. Each has strengths and ideal use cases. Here is a comprehensive comparison to help you choose the right tool for each situation.

Aspect instanceof Pattern Matching switch Pattern Matching Visitor Pattern
Introduced Java 16 (JEP 394) Java 21 (JEP 441) GoF Pattern (any version)
Syntax if (obj instanceof Type t) case Type t -> visitor.visit(element)
Exhaustiveness No — compiler cannot verify all types handled Yes — with sealed types, compiler enforces completeness Yes — interface methods enforce completeness
Null handling Must check separately case null built-in Must check separately
Expression form No — requires separate return/assign Yes — switch can be an expression Depends on implementation
Guard conditions Regular if conditions after check when clause inline Logic inside visit methods
Boilerplate Low Low High — visitor interface, accept methods
Adding new types Must find all if-else chains manually Compiler finds all switches (sealed types) Add method to visitor interface (compiler finds all)
Adding new operations Easy — add new if-else chain Easy — add new switch Add new visitor implementation
Nested patterns Requires nested if-else Supports nested record patterns Not applicable
Best for Simple single-type checks, 1-2 types Multi-type dispatching, domain modeling When operations must be in separate classes

When to Use Each

  • Use instanceof when you are checking one or two types in a simple conditional. No need for a switch when you just need “if this is a String, do X.”
  • Use switch pattern matching when you are dispatching over three or more types, especially with sealed hierarchies. This is the default choice for domain modeling in Java 21+.
  • Use the Visitor pattern when operations need to be defined in separate classes (e.g., plugins, modular systems) or when you need double dispatch. The Visitor pattern separates the operation from the data structure, which pattern matching does not do.

12. Best Practices

Pattern matching for switch is a powerful feature, but like any powerful tool, it can be misused. Here are the best practices I have learned from using it in production code.

1. Order Cases from Most Specific to Most General

The compiler enforces dominance rules, but within non-dominating cases, order still affects which case runs first for guarded patterns. Put your most specific, most restrictive cases first.

// GOOD: specific to general
return switch (obj) {
    case String s when s.isEmpty()  -> "empty";
    case String s when s.isBlank()  -> "blank";
    case String s                   -> "text: " + s;
    case Number n                   -> "number: " + n;
    default                         -> "other";
};

// BAD (conceptually): less restrictive guard before more restrictive
// While this may compile, the semantics might surprise you
return switch (obj) {
    case String s when s.isBlank()  -> "blank";     // isEmpty() is also blank
    case String s when s.isEmpty()  -> "empty";     // This NEVER matches -- empty is always blank
    case String s                   -> "text: " + s;
    default                         -> "other";
};

2. Prefer Sealed Types Over default

Whenever you control the type hierarchy, make it sealed. The default branch hides missing cases. A sealed type with no default turns missing cases into compile errors.

// GOOD: sealed type, no default
public sealed interface Shape permits Circle, Rectangle, Triangle {}

public double area(Shape s) {
    return switch (s) {
        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();
        // Compiler verifies this is exhaustive -- no default needed
    };
}

// AVOID: non-sealed type with default that hides missing cases
public double area(Object s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        default          -> 0.0; // Silently returns 0 for triangles, pentagons, etc.
    };
}

3. Keep Cases Simple — Extract Complex Logic

If a case body grows beyond 3-4 lines, extract it into a method. The switch should read as a dispatch table, not contain all your business logic.

// GOOD: switch as a clean dispatch table
public CommandResult execute(Command cmd) {
    return switch (cmd) {
        case CreateUser cu      -> handleCreate(cu);
        case UpdateUser uu      -> handleUpdate(uu);
        case DeleteUser du      -> handleDelete(du);
        case ResetPassword rp   -> handleReset(rp);
    };
}

// AVOID: complex logic inline
public CommandResult execute(Command cmd) {
    return switch (cmd) {
        case CreateUser(var email, var name) -> {
            validateEmail(email);
            validateName(name);
            var user = new User(email, name);
            userRepository.save(user);
            emailService.sendWelcome(email);
            auditLog.record("CREATE", user.id());
            yield new Success("Created", user);
        }
        // ... many more lines per case
    };
}

4. Handle null Explicitly

If your switch selector can be null, handle it with case null. Do not rely on the caller to check for null. Being explicit about null handling documents the contract of your method.

// GOOD: explicit null handling
public String format(Object value) {
    return switch (value) {
        case null       -> "N/A";
        case String s   -> s;
        case Integer i  -> String.format("%,d", i);
        default         -> value.toString();
    };
}

// AVOID: hoping the caller checks for null
public String format(Object value) {
    // Throws NPE if value is null -- surprise!
    return switch (value) {
        case String s   -> s;
        case Integer i  -> String.format("%,d", i);
        default         -> value.toString();
    };
}

5. Use var for Record Patterns When Types Are Obvious

When deconstructing records, var reduces noise if the types are obvious from context. Use explicit types when clarity is needed.

// GOOD: var when types are obvious from the record definition
case Point(var x, var y) -> ...      // Point has int x, int y -- obvious
case Employee(var name, var dept) -> ...  // Clearly String, String

// GOOD: explicit types when working with complex or generic records
case Pair(String key, Integer value) -> ...
case Result>(List employees) -> ...

6. Prefer switch Expressions Over switch Statements

When the switch produces a value, use it as an expression. This prevents accidentally forgetting to return or assign in one branch.

// GOOD: switch expression
String label = switch (status) {
    case Active a   -> "Active since " + a.since();
    case Inactive i -> "Inactive: " + i.reason();
    case Pending p  -> "Pending approval";
};

// AVOID: switch statement when an expression works
String label;
switch (status) {
    case Active a   -> label = "Active since " + a.since();
    case Inactive i -> label = "Inactive: " + i.reason();
    case Pending p  -> label = "Pending approval";
    // Easy to forget 'label =' in one branch
}

Summary of Best Practices

Practice Why
Order cases specific to general Prevents dominance errors and ensures correct matching
Use sealed types, avoid default Compiler catches missing cases when hierarchy changes
Keep cases simple, extract methods Switch reads as a dispatch table, not a logic dump
Handle null explicitly Documents the null contract, prevents surprise NPEs
Use var for obvious record types Reduces noise without sacrificing clarity
Prefer switch expressions Ensures every branch produces a value, prevents omissions
Combine sealed types + records + patterns Maximum type safety with minimum boilerplate

Pattern matching for switch is one of the most significant additions to Java in years. It transforms how you model domains and dispatch on types. Combined with sealed classes and records, it brings Java closer to the expressiveness of functional languages while retaining the type safety and tooling that make Java great for large-scale applications.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

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