Java 21 — Pattern Matching for switch (Exhaustive & Guarded)
Master Java 21's pattern matching for switch — type patterns, guarded patterns, null handling, exhaustiveness, and practical examples for cleaner code.
Java’s switch statement has evolved from a simple value matcher to a powerful pattern matching tool. Java 21 finalized pattern matching for switch — type patterns, guards, null handling, and exhaustive checking.
This post shows you everything that’s new and how to use it.
The old way
Before Java 21, checking types required cascading instanceof:
// Verbose instanceof chains
static String format(Object obj) {
if (obj instanceof Integer i) {
return "Integer: " + i;
} else if (obj instanceof String s) {
return "String: " + s;
} else if (obj instanceof Double d) {
return "Double: " + d;
} else if (obj == null) {
return "null";
} else {
return "Unknown: " + obj;
}
}
The new way — Pattern matching in switch
static String format(Object obj) {
return switch (obj) {
case Integer i -> "Integer: " + i;
case String s -> "String: " + s;
case Double d -> "Double: " + d;
case null -> "null";
default -> "Unknown: " + obj;
};
}
Cleaner. Each case is a type pattern that checks the type and binds the variable in one step.
Guarded patterns
Add conditions to patterns with when:
static String categorize(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 > 1000 -> "Large integer: " + i;
case Integer i -> "Small positive: " + i;
case String s when s.isEmpty() -> "Empty string";
case String s when s.length() > 100 -> "Long string (" + s.length() + " chars)";
case String s -> "String: " + s;
case null -> "null";
default -> "Other: " + obj;
};
}
Guards (when) let you add arbitrary boolean conditions to type patterns. More specific cases must come before less specific ones — the compiler checks this.
Null handling
Before Java 21, switch threw NullPointerException on null input. Now null is a first-class pattern:
static String describe(String s) {
return switch (s) {
case null -> "null value";
case "" -> "empty string";
case String str when str.length() > 100 -> "long string";
case String str -> "string: " + str;
};
}
You can also combine null with other patterns:
case null, default -> "null or unmatched";
Exhaustiveness
When switch is used as an expression (returns a value), it must be exhaustive — every possible input must be handled:
// Compile error — not exhaustive (missing null, other types)
String result = switch (obj) {
case Integer i -> "int";
case String s -> "string";
// error: switch expression does not cover all possible input values
};
// Fixed with default
String result = switch (obj) {
case Integer i -> "int";
case String s -> "string";
default -> "other";
};
Sealed classes + exhaustive switch
The real power shows with sealed classes:
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 {}
static 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();
// No default needed — all subtypes are covered
};
}
If you add a new Shape subtype later (e.g., Pentagon), the compiler flags every switch that doesn’t handle it. This is the same exhaustive checking that Kotlin sealed classes provide.
Record patterns
Deconstruct records directly in switch:
record Point(int x, int y) {}
static String describe(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;
case Point(int x, int y) when y == 0 -> "On X-axis at " + x;
case Point(int x, int y) -> "Point(%d, %d)".formatted(x, y);
default -> "Not a point";
};
}
Nested record patterns:
record Address(String city, String state) {}
record Customer(String name, Address address) {}
static String getCity(Object obj) {
return switch (obj) {
case Customer(var name, Address(var city, _)) -> city;
default -> "unknown";
};
}
The _ is an unnamed pattern — it matches anything but doesn’t bind a variable (Java 25 feature, but illustrates the direction).
Practical examples
API response handling
sealed interface ApiResponse<T> permits Success, ClientError, ServerError {}
record Success<T>(T data, int status) implements ApiResponse<T> {}
record ClientError<T>(String message, int status) implements ApiResponse<T> {}
record ServerError<T>(String message, int status) implements ApiResponse<T> {}
static <T> T handleResponse(ApiResponse<T> response) {
return switch (response) {
case Success<T>(var data, _) -> data;
case ClientError(var msg, var status) when status == 404 ->
throw new NotFoundException(msg);
case ClientError(var msg, var status) when status == 401 ->
throw new UnauthorizedException(msg);
case ClientError(var msg, _) ->
throw new BadRequestException(msg);
case ServerError(var msg, _) ->
throw new ServiceUnavailableException(msg);
};
}
Command processing
sealed interface Command permits CreateUser, UpdateUser, DeleteUser, ResetPassword {}
record CreateUser(String name, String email) implements Command {}
record UpdateUser(String id, String name) implements Command {}
record DeleteUser(String id) implements Command {}
record ResetPassword(String email) implements Command {}
void process(Command command) {
switch (command) {
case CreateUser(var name, var email) -> userService.create(name, email);
case UpdateUser(var id, var name) -> userService.update(id, name);
case DeleteUser(var id) -> userService.delete(id);
case ResetPassword(var email) -> authService.resetPassword(email);
}
}
Expression tree evaluation
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}
static double eval(Expr expr) {
return switch (expr) {
case Num(var 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);
};
}
// Usage
Expr expression = new Add(new Num(1), new Mul(new Num(2), new Num(3)));
double result = eval(expression); // 7.0
Pattern matching order
Patterns are checked top to bottom. More specific patterns must come first:
// Correct — specific before general
case Integer i when i < 0 -> "negative";
case Integer i -> "non-negative";
// Compile error — unreachable pattern
case Integer i -> "any integer";
case Integer i when i < 0 -> "negative"; // never reached!
The compiler checks for dominance — if a pattern is unreachable because a previous pattern always matches, it’s a compile error.
Migration from if-else chains
Before:
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");
}
After:
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 switch version is shorter, exhaustive (compiler-checked), and doesn’t need a throw for the unknown case.
Summary
| Feature | Syntax |
|---|---|
| Type pattern | case Integer i -> |
| Guarded pattern | case Integer i when i > 0 -> |
| Null pattern | case null -> |
| Record pattern | case Point(int x, int y) -> |
| Combined null+default | case null, default -> |
| Exhaustive check | Automatic for sealed types |
Pattern matching transforms Java’s switch from a value-matching statement into a full type-matching expression. Combined with sealed classes and records, it gives Java the same kind of exhaustive pattern matching that Kotlin has had with sealed classes and when.