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 pattern — default dominates all subsequent cases. Always put default last.
Using case String s without a default for non-sealed types — Object 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 whenclauses add boolean guards to any pattern — cases match top-to-bottom, first match winscase nullhandles null explicitly — without it, null still throwsNullPointerException- 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
yieldreturns 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.