A brief history of switch in Java
Switch in Java evolved in stages:
- Java 14 (JEP 361) — switch expressions: produce a value, arrow
->syntax, no fall-through. - Java 16 (JEP 394) — pattern matching for
instanceof: bind a variable in one step. - Java 17–20 — switch pattern matching in preview (JEP 406, 420, 427, 433).
- Java 21 (JEP 441) — switch pattern matching finalised: type patterns, guarded patterns, record deconstruction,
nullcases.
Understanding the full picture — from basic switch expressions to exhaustive pattern-matching switches — is what separates a strong modern Java answer from a mediocre one.
Switch expressions (Java 14)
A switch expression produces a value and can be used anywhere an expression is expected. The key differences from the traditional switch statement:
// Traditional switch statement:
String label;
switch (day) {
case MONDAY:
case TUESDAY:
label = "early week";
break;
default:
label = "later";
}
// Switch expression (Java 14):
String label = switch (day) {
case MONDAY, TUESDAY -> "early week";
default -> "later";
};
Arrow labels (->) don't fall through and don't need break. Multiple labels per case are comma-separated. The expression must be exhaustive — the compiler rejects a switch expression that doesn't cover all possible values.
yield in block arms
When a case arm needs multiple statements, use a block with yield to return a value:
int fee = switch (tier) {
case "gold" -> 0;
case "silver" -> 5;
default -> {
int base = 10;
int penalty = tier.length();
yield base + penalty; // returns the value from this arm
}
};
yield is to switch expressions what return is to methods — scoped to the expression, not the enclosing method.
Pattern matching for instanceof (Java 16)
Before Java 16, testing a type and then using it required two steps:
// Old way — redundant cast:
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
Pattern matching for instanceof (JEP 394) collapses the test and binding into one:
if (obj instanceof String s) {
System.out.println(s.toUpperCase()); // s is in scope and typed
}
The bound variable s is in scope only when the pattern matches. It also works in compound expressions:
if (obj instanceof String s && s.length() > 5) {
System.out.println("long string: " + s);
}
The compiler is smart enough to know s is only accessible in the && branch where the test succeeded.
Type patterns in switch (Java 21)
Switch pattern matching extends instanceof patterns to multi-way dispatch:
Object obj = getValue();
String desc = switch (obj) {
case Integer i -> "int: " + i;
case Long l -> "long: " + l;
case String s -> "str: " + s;
case null -> "null";
default -> "other: " + obj.getClass().getName();
};
This replaces chains of if (obj instanceof X x) ... else if (obj instanceof Y y) ... with a single, readable, exhaustiveness-checked construct. The selector can be any reference type — not just int, String, or enum as with traditional switch.
Guarded patterns — the when clause
A when clause adds a boolean condition to a type pattern:
String classify(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "negative";
case Integer i when i == 0 -> "zero";
case Integer i -> "positive";
case String s when s.isEmpty() -> "empty string";
case String s -> "non-empty string";
default -> "unknown";
};
}
Cases are tested top-to-bottom. The when guard is evaluated only if the type pattern matches first. Always put the most specific guards earlier — a later catch-all case for the same type handles the remaining values.
Exhaustiveness with sealed types
The most powerful feature: when the selector is a sealed type, the compiler verifies that every permitted subtype is handled. No default needed, and adding a new subtype breaks the switch at compile time, forcing you to update every exhaustive switch.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double r) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Rectangle r -> r.w() * r.h();
};
// Compiler error if Triangle is added to Shape without handling it here
}
This is exhaustiveness you'd previously get only from enums — now available for any closed type hierarchy.
Record deconstruction in switch
Record patterns (Java 21, JEP 440) let you destructure a record's components directly in a case:
sealed interface Expr permits Num, Add, Mul {}
record Num(int v) implements Expr {}
record Add(Expr l, Expr r) implements Expr {}
record Mul(Expr l, Expr r) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
System.out.println(eval(new Add(new Num(3), new Mul(new Num(4), new Num(5))))); // 23
Nested deconstruction also works — components that are themselves records can be destructured inline:
record Point(int x, int y) {}
record Segment(Point start, Point end) {}
String describe(Segment seg) {
return switch (seg) {
case Segment(Point(int x1, int y1), Point(int x2, int y2))
-> "(%d,%d)→(%d,%d)".formatted(x1, y1, x2, y2);
};
}
No accessor calls, no explicit casts — the structure is declared in the pattern itself.
Handling null explicitly
Traditional switch throws NullPointerException if the selector is null. Java 21 allows case null to handle it explicitly:
switch (input) {
case null -> System.out.println("got null");
case "yes", "true" -> System.out.println("affirmative");
default -> System.out.println("other: " + input);
}
You can combine: case null, default -> handles both null and unmatched values together. If no case null is present, null still throws NPE for backward compatibility.
Dominance — ordering matters
When using type patterns, more specific types must appear before more general ones. The compiler rejects dominated cases — cases that can never be reached because a prior case already matches every value:
// Compile error:
switch (obj) {
case Object o -> System.out.println("any"); // dominates everything
case String s -> System.out.println("string"); // unreachable!
}
// Correct — most specific first:
switch (obj) {
case String s -> System.out.println("string");
case Integer i -> System.out.println("int");
default -> System.out.println("other");
}
Rule of thumb: order from most specific to most general, same as catch blocks.
Switch pattern matching vs polymorphism
Switch pattern matching is not a replacement for polymorphism — it's a complement:
| Situation | Prefer |
|---|---|
Operation belongs to the type (area(), validate()) | Polymorphism |
| Operation is external (serialisation, logging, rendering) | Switch patterns |
| Open hierarchy — third parties add subtypes | Polymorphism |
| Closed hierarchy (sealed types) | Switch patterns |
| Compile-time exhaustiveness required | Switch patterns |
The classic trade-off: adding a new operation on a closed type is easy with switch (add one function); adding a new type variant to an open type is easy with polymorphism (add one class). Sealed + switch inverts this: new type variants break existing switches (good — the compiler guides you), but new operations require no changes to existing classes.
Real-world example: JSON value dispatch
sealed interface JsonValue permits
JsonString, JsonNumber, JsonBool, JsonNull, JsonArray, JsonObject {}
record JsonString(String value) implements JsonValue {}
record JsonNumber(double value) implements JsonValue {}
record JsonBool(boolean value) implements JsonValue {}
record JsonNull() implements JsonValue {}
record JsonArray(List<JsonValue> items) implements JsonValue {}
record JsonObject(Map<String, JsonValue> fields) implements JsonValue {}
String toDisplayString(JsonValue v) {
return switch (v) {
case JsonString(String s) -> "\"" + s + "\"";
case JsonNumber(double n) -> n % 1 == 0
? String.valueOf((long) n) : String.valueOf(n);
case JsonBool(boolean b) -> String.valueOf(b);
case JsonNull() -> "null";
case JsonArray(var items) -> items.stream()
.map(this::toDisplayString).collect(joining(",", "[", "]"));
case JsonObject(var fields) -> fields.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\":" + toDisplayString(e.getValue()))
.collect(joining(",", "{", "}"));
};
}
The switch is exhaustive (all six JsonValue subtypes covered), the components are
destructured inline, and adding a new JSON type to the sealed interface immediately
breaks this method at compile time — intentional and safe.
Recap
Switch expressions (Java 14) produce values, use arrow syntax, and are exhaustively
checked. Pattern matching for instanceof (Java 16) binds a typed variable in a
single test. Switch pattern matching (Java 21) extends this to multi-way type
dispatch with type patterns, guarded patterns (when), record
deconstruction, and explicit case null handling. When the selector is a
sealed type, the compiler verifies exhaustiveness — adding a new permitted subtype
breaks every unhandled switch at compile time. Order cases from most to least specific
to avoid dominance errors. This combination of sealed interfaces + records + switch
pattern matching is the modern Java replacement for the Visitor pattern — less ceremony,
compile-time safety, and no external visitor infrastructure.