Contents

// 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.