Pattern Matching for switch (Java 21+)

Java 21 made pattern matching for switch a standard feature (JEP 441). It extends switch to accept type patterns, guarded patterns with when, and even null, turning verbose instanceof-chain if/else blocks into clean, exhaustive, compiler-checked switch expressions.

Contents

Before and After

Traditional switch only matched on int, String, and enum values. Type dispatch, routing behavior based on runtime type, required verbose instanceof chains that were error-prone, non-exhaustive, and repeated the binding pattern on every branch. Pattern matching for switch (JEP 441, standard in Java 21) supports type patterns directly in case labels, binds the value in the same step, and allows when guards for additional conditions. When the selector type is a sealed class, the compiler checks exhaustiveness, missing a subtype is a compile error, not a silent runtime bug.

Java
// BEFORE — instanceof chain if/else
static String format(Object obj) {
    if (obj instanceof Integer i)       return "int: " + i;
    else if (obj instanceof Double d)   return "double: " + d;
    else if (obj instanceof String s && s.length() > 5) return "long string: " + s;
    else if (obj instanceof String s)   return "short string: " + s;
    else if (obj == null)               return "null";
    else return "other: " + obj;
}

// AFTER — switch with type patterns
static String format(Object obj) {
    return switch (obj) {
        case Integer i                       -> "int: " + i;
        case Double d                        -> "double: " + d;
        case String s when s.length() > 5   -> "long string: " + s;
        case String s                        -> "short string: " + s;
        case null                            -> "null";
        default                              -> "other: " + obj;
    };
}

Type Patterns

Each case label can be a type pattern: a type followed by a binding variable name. The variable is bound when that case is selected.

Java
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius)               implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height)  implements Shape {}

// Exhaustive switch — no default needed with sealed types
static 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();
    };
}

// Ordering matters: more specific patterns must come before broader ones
static String describe(Number n) {
    return switch (n) {
        case Integer i  -> "Integer: " + i;
        case Long l     -> "Long: " + l;
        case Double d   -> "Double: " + d;
        default         -> "Other number: " + n;
        // case Number x -> ...  would be a compile error here: it dominates all above
    };
}
A broader type pattern dominates narrower ones. If you place case Number n before case Integer i, the compiler will report a dominance error.

Guarded Patterns with when

The when clause adds a boolean guard to a type pattern. The binding variable is in scope within the guard expression.

Java
static String classify(Object obj) {
    return switch (obj) {
        case Integer i when i < 0        -> "negative int";
        case Integer i when i == 0       -> "zero";
        case Integer i when i % 2 == 0   -> "even positive: " + i;
        case Integer i                   -> "odd positive: " + i;
        case String s when s.isBlank()   -> "blank string";
        case String s                    -> "string: " + s;
        case null                        -> "null";
        default                          -> "other: " + obj;
    };
}

// Record + when guard — very expressive
record Order(String id, double total, String status) {}

static String orderLabel(Object obj) {
    return switch (obj) {
        case Order o when "PAID".equals(o.status()) && o.total() > 1000
            -> "VIP paid: " + o.id();
        case Order o when "PAID".equals(o.status())
            -> "Paid: " + o.id();
        case Order o when "PENDING".equals(o.status()) && o.total() > 500
            -> "High-value pending: " + o.id();
        case Order o
            -> "Order: " + o.id() + " [" + o.status() + "]";
        default
            -> "Not an order";
    };
}

Null Handling in switch

Before Java 21, passing null to a switch always threw NullPointerException. Now you can explicitly handle null with case null:

Java
static void handle(String input) {
    switch (input) {
        case null           -> System.out.println("No input");
        case ""             -> System.out.println("Empty");
        case String s when s.isBlank() -> System.out.println("Blank");
        default             -> System.out.println("Input: " + input);
    }
}

// null and default can be combined
switch (value) {
    case null, "UNKNOWN" -> System.out.println("Not available");
    case "ACTIVE"        -> System.out.println("Active");
    default              -> System.out.println("Other: " + value);
}
Without a case null, passing null to a switch still throws NullPointerException: the same as before Java 21. You must explicitly opt into null handling.

Sealed Classes and Exhaustiveness

When the selector is a sealed type, the compiler enforces exhaustiveness, missing a permitted subtype is a compile error, not a runtime surprise.

Java
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(int value)             implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr)             implements Expr {}

// No default needed — sealed hierarchy is fully covered
static int eval(Expr expr) {
    return switch (expr) {
        case Num(int v)          -> v;
        case Add(var l, var r)   -> eval(l) + eval(r);
        case Mul(var l, var r)   -> eval(l) * eval(r);
        case Neg(var e)          -> -eval(e);
    };
}

// Result<T> — sealed generic interface
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value)    implements Result<T> {}
record Err<T>(String msg) implements Result<T> {}

static <T> T unwrap(Result<T> result) {
    return switch (result) {
        case Ok<T>(var v)   -> v;
        case Err<T>(var msg) -> throw new RuntimeException("Error: " + msg);
    };
}
Adding a new permitted subtype to a sealed interface will break all exhaustive switches over that type at compile time, which is intentional and desirable. It forces you to handle every case.