Part 3 of 15

Pattern Matching for switch (JEP 441): Type Dispatch Without the Boilerplate

The Problem: Cascading instanceof Chains

Any Java codebase handling multiple subtypes has code like this:

// Java 16 and earlier — the bad old way
static double calculateArea(Shape shape) {
    if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return Math.PI * c.radius() * c.radius();
    } else if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return r.width() * r.height();
    } else if (shape instanceof Triangle) {
        Triangle t = (Triangle) shape;
        return 0.5 * t.base() * t.height();
    } else {
        throw new IllegalArgumentException("Unknown shape: " + shape);
    }
}

Problems: verbose, error-prone casts, no compiler check for missing cases, null falls through silently. Pattern matching for switch fixes all of this.


Type Patterns in switch

// Java 21 — clean, safe, exhaustive
static 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();
    };
}

Each case both tests the type and binds the value to a typed variable. No cast needed. The compiler verifies exhaustiveness — if Shape is a sealed interface, forgetting Triangle is a compile error.


Evolution: How We Got Here

flowchart LR
    J14["Java 14\nSwitch Expressions\nfinal JEP 361\ncase X -> value"]
    J16["Java 16\ninstanceof patterns\nfinal JEP 394\nif obj instanceof String s"]
    J17["Java 17\nPattern switch\npreview JEP 406"]
    J21["Java 21\nPattern switch\nfinal JEP 441\nAll cases + guards + null"]

    J14 --> J16 --> J17 --> J21

JEP 441 is the culmination of 5 years of refinement across 4 Java releases.


Guard Clauses with when

Refine a pattern with a boolean condition using when:

static String classify(Object obj) {
    return switch (obj) {
        case Integer i when i < 0  -> "negative integer: " + i;
        case Integer i when i == 0 -> "zero";
        case Integer i             -> "positive integer: " + i;
        case String s  when s.isEmpty() -> "empty string";
        case String s               -> "string: " + s;
        case null                   -> "null";
        default                     -> "other: " + obj;
    };
}

Cases are evaluated top to bottom — the first matching case wins. A when clause that returns false falls through to the next case.

flowchart TD
    Input["obj = -5"]
    C1{"case Integer i\nwhen i < 0?"}
    C2{"case Integer i\nwhen i == 0?"}
    C3{"case Integer i"}
    Result["-5 matches → 'negative integer: -5'"]

    Input --> C1
    C1 -->|"i=-5, -5<0 true"| Result
    C1 -->|false| C2
    C2 -->|false| C3

Null Handling

Before Java 21, a null value passed to switch threw NullPointerException. Now null is a first-class case:

static String describeOrder(Order order) {
    return switch (order) {
        case null                               -> "No order";
        case Order o when o.isPending()         -> "Pending: " + o.id();
        case Order o when o.isCompleted()       -> "Completed: " + o.id();
        case Order o                            -> "Order " + o.id() + " in state " + o.status();
    };
}

Without case null, passing null still throws NullPointerException — the old behaviour is preserved unless you explicitly add it.


Exhaustiveness — The Compiler Has Your Back

With Sealed Interfaces

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

// Compiler knows Shape can only be Circle, Rectangle, or Triangle
static double area(Shape s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        // Compile error: Triangle not covered!
    };
}

The compiler verifies the switch covers every permitted subtype. Adding a new permitted subtype causes a compile error at every switch on the sealed type — forcing you to handle the new case.

With Non-Sealed Types

For non-sealed types, default is required:

static String describe(Object o) {
    return switch (o) {
        case String s  -> "String";
        case Integer i -> "Integer";
        default        -> "Something else";  // required
    };
}

With Enums

Pattern switch is exhaustive for enums when all constants are covered:

enum Status { PENDING, ACTIVE, CANCELLED, COMPLETED }

static String label(Status s) {
    return switch (s) {
        case PENDING   -> "Waiting";
        case ACTIVE    -> "In Progress";
        case CANCELLED -> "Cancelled";
        case COMPLETED -> "Done";
        // No default needed — all enum constants covered
    };
}

Adding a new enum constant REFUNDED causes a compile error at this switch.


switch Statement vs switch Expression

Pattern matching works in both:

// switch EXPRESSION — returns a value
double area = switch (shape) {
    case Circle c    -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.width() * r.height();
};

// switch STATEMENT — performs an action, no return
switch (shape) {
    case Circle c    -> renderCircle(c);
    case Rectangle r -> renderRectangle(r);
}

// Block bodies with yield (expression only)
double area = switch (shape) {
    case Circle c -> {
        double r = c.radius();
        yield Math.PI * r * r;  // yield returns from the block
    }
    case Rectangle r -> r.width() * r.height();
};

Combining with Sealed Classes — The Full Pattern

This is the idiomatic Java 21 approach to algebraic data types:

sealed interface Result<T> permits Result.Success, Result.Failure {}

record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error, Exception cause) implements Result<T> {}

static <T> String describe(Result<T> result) {
    return switch (result) {
        case Success<T> s  -> "OK: " + s.value();
        case Failure<T> f  -> "Error: " + f.error();
    };
}

// With guards
static <T> String describe(Result<T> result) {
    return switch (result) {
        case Success<T> s  when s.value() == null -> "OK but null value";
        case Success<T> s                         -> "OK: " + s.value();
        case Failure<T> f  when f.cause() != null -> "Error with exception: " + f.error();
        case Failure<T> f                         -> "Error: " + f.error();
    };
}

Dominance — Case Ordering Rules

The compiler enforces that more specific patterns appear before less specific ones:

// Compile error — Integer i dominates Integer i when i > 0
// (the general pattern catches everything the specific one does)
switch (obj) {
    case Integer i        -> "any integer";
    case Integer i when i > 0 -> "positive integer";  // ERROR: dominated
}

// Correct — specific first
switch (obj) {
    case Integer i when i > 0 -> "positive integer";
    case Integer i            -> "any other integer";
}

Practical Example: HTTP Response Handling

sealed interface HttpResult permits HttpResult.Ok, HttpResult.ClientError, HttpResult.ServerError {}
record Ok(int status, String body) implements HttpResult {}
record ClientError(int status, String message) implements HttpResult {}
record ServerError(int status, String message, Exception cause) implements HttpResult {}

static void handle(HttpResult result) {
    switch (result) {
        case Ok(int status, String body) when status == 200 ->
            log.info("Success: {}", body);

        case Ok(int status, String body) ->
            log.info("Other success ({}): {}", status, body);

        case ClientError(int status, String msg) when status == 404 ->
            log.warn("Not found: {}", msg);

        case ClientError(int status, String msg) ->
            log.error("Client error {}: {}", status, msg);

        case ServerError(int status, String msg, Exception ex) ->
            log.error("Server error {}: {}", status, msg, ex);
    }
}

This combines record patterns (destructuring) with type patterns and guard clauses — the full power of Java 21’s pattern system in one switch.


Common Mistakes

Putting default before a type patterndefault dominates all subsequent cases. Always put default last.

Using case String s without a default for non-sealed typesObject has infinite possible subtypes; the compiler requires default.

Forgetting when guards are not short-circuited between cases — each case is evaluated independently from top to bottom. A when failure does not skip the entire type; it falls to the next matching case.

Null without case null — null still throws NullPointerException unless you add case null explicitly.


Key Takeaways

  • Type patterns in switch bind and cast in one step — no more manual instanceof + cast
  • when clauses add boolean guards to any pattern — cases match top-to-bottom, first match wins
  • case null handles null explicitly — without it, null still throws NullPointerException
  • The compiler enforces exhaustiveness for sealed types and enums — missing cases are compile errors
  • Dominance rules prevent unreachable patterns — specific patterns must precede general ones
  • yield returns a value from a block-bodied switch expression arm
  • Pattern switch works in both switch expressions (returning values) and switch statements (performing actions)

Next: Record Patterns (JEP 440) — destructure nested records in a single pattern expression without intermediate variable assignments.