Contents

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.

// 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; }; }

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.

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.

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

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"; }; }

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

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.

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

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.