Contents

When you switch over a sealed type the compiler can verify that every permitted subtype is covered. No default branch is needed — and the compiler will tell you if you later add a new permitted subtype and forget to handle it in existing switches.

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 {} // Java 21+ pattern switch — exhaustive, no default needed 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(); }; } System.out.println(area(new Circle(5))); // 78.539... System.out.println(area(new Rectangle(3, 4))); // 12.0 System.out.println(area(new Triangle(6, 8))); // 24.0 If you add a fourth class (Pentagon) to the permits clause, the compiler immediately flags every switch that is no longer exhaustive — a compile-time safety net you cannot get with open hierarchies. // Switch expression or statement — both supported String describe(Shape s) { return switch (s) { case Circle c -> "Circle with radius " + c.radius(); case Rectangle r -> "Rectangle " + r.width() + "x" + r.height(); case Triangle t -> "Triangle base=" + t.base(); }; }

A guarded pattern uses the when clause to add a boolean condition to a type pattern. The arm fires only if both the type test and the guard are true.

sealed interface Payment permits CashPayment, CardPayment, CryptoPayment {} record CashPayment(double amount) implements Payment {} record CardPayment(double amount, String cardType) implements Payment {} record CryptoPayment(double amount, String coin) implements Payment {} String processFee(Payment p) { return switch (p) { case CashPayment c when c.amount() > 1_000 -> "Cash: large amount — verify ID"; case CashPayment c -> "Cash: standard"; case CardPayment cp when "AMEX".equals(cp.cardType()) -> "Card: AMEX surcharge applies"; case CardPayment cp -> "Card: " + cp.cardType(); case CryptoPayment cr when "BTC".equals(cr.coin()) -> "Crypto: BTC — high volatility"; case CryptoPayment cr -> "Crypto: " + cr.coin(); }; } System.out.println(processFee(new CashPayment(500))); // Cash: standard System.out.println(processFee(new CashPayment(5000))); // Cash: large amount — verify ID System.out.println(processFee(new CardPayment(100, "AMEX"))); // Card: AMEX surcharge applies System.out.println(processFee(new CryptoPayment(200, "ETH"))); // Crypto: ETH More specific guarded patterns must come before the general type pattern for the same type, otherwise the compiler reports a dominance error — a guarded case that could never be reached. // Dominance error — general case before guarded case String bad(Shape s) { return switch (s) { case Circle c -> "any circle"; // dominates the next arm case Circle c when c.radius() > 10 -> "big"; // compile error: dominated case Rectangle r -> "rectangle"; case Triangle t -> "triangle"; }; }

A record pattern deconstructs a record's components directly in the case label, binding named variables without an extra line of code.

sealed interface Notification permits EmailNotif, SmsNotif, PushNotif {} record EmailNotif(String to, String subject, String body) implements Notification {} record SmsNotif(String phone, String text) implements Notification {} record PushNotif(String deviceId, String title) implements Notification {} String summary(Notification n) { return switch (n) { // Record patterns — destructure directly in case case EmailNotif(var to, var subject, var body) -> "Email to " + to + ": " + subject; case SmsNotif(var phone, var text) -> "SMS to " + phone + ": " + text.substring(0, Math.min(20, text.length())); case PushNotif(var device, var title) -> "Push [" + device + "]: " + title; }; } System.out.println(summary(new EmailNotif("a@b.com", "Hi", "Hello world"))); // Email to a@b.com: Hi

Nested record patterns work too — you can destructure a record that contains another record in one pattern:

record Point(double x, double y) {} record Line(Point start, Point end) {} String describeHorizontal(Object obj) { return switch (obj) { // Nested record pattern case Line(Point(var x1, var y1), Point(var x2, var y2)) when y1 == y2 -> "Horizontal line at y=" + y1; case Line l -> "Non-horizontal line"; default -> "Not a line"; }; } System.out.println(describeHorizontal(new Line(new Point(0, 5), new Point(10, 5)))); // Horizontal line at y=5.0

Classic switches throw NullPointerException when the selector is null. Pattern switches let you handle null explicitly with a case null arm.

sealed interface Status permits Ok, Err {} record Ok(String value) implements Status {} record Err(String error) implements Status {} String render(Status s) { return switch (s) { case null -> "No status available"; case Ok(var v) -> "✓ " + v; case Err(var e) -> "✗ " + e; }; } System.out.println(render(null)); // No status available System.out.println(render(new Ok("Done"))); // ✓ Done System.out.println(render(new Err("404"))); // ✗ 404

You can also combine case null with a default arm using a comma:

String renderFallback(Status s) { return switch (s) { case Ok(var v) -> "OK: " + v; case null, default -> "Unknown/null status"; }; }

A classic use-case for sealed + pattern matching is a small expression language. Each node type is a sealed record; evaluation is a single recursive switch with no casts.

import java.util.Map; // --- AST node hierarchy --- sealed interface Expr permits Num, Var, Add, Mul, Neg, Div {} record Num(double value) implements Expr {} record Var(String name) implements Expr {} record Add(Expr l, Expr r) implements Expr {} record Mul(Expr l, Expr r) implements Expr {} record Neg(Expr expr) implements Expr {} record Div(Expr l, Expr r) implements Expr {} // --- Evaluator --- double eval(Expr expr, Map<String, Double> env) { return switch (expr) { case Num(var v) -> v; case Var(var n) -> env.getOrDefault(n, 0.0); case Add(var l, var r) -> eval(l, env) + eval(r, env); case Mul(var l, var r) -> eval(l, env) * eval(r, env); case Neg(var e) -> -eval(e, env); case Div(var l, var r) when eval(r, env) != 0 -> eval(l, env) / eval(r, env); case Div(_, _) -> throw new ArithmeticException("Division by zero"); }; } // --- Pretty printer --- String print(Expr expr) { return switch (expr) { case Num(var v) -> String.valueOf(v); case Var(var n) -> n; case Add(var l, var r) -> "(" + print(l) + " + " + print(r) + ")"; case Mul(var l, var r) -> "(" + print(l) + " * " + print(r) + ")"; case Neg(var e) -> "-" + print(e); case Div(var l, var r) -> "(" + print(l) + " / " + print(r) + ")"; }; } // Evaluate: (x + 2) * (y - 1) var env = Map.of("x", 3.0, "y", 5.0); Expr ast = new Mul( new Add(new Var("x"), new Num(2)), new Add(new Var("y"), new Neg(new Num(1))) ); System.out.println(print(ast)); // ((x + 2.0) * (y + -1.0)) System.out.println(eval(ast, env)); // 20.0 (3+2)*(5-1) This pattern — sealed hierarchy + record components + recursive switch — is sometimes called a tagless-final style in Java. Adding a new operation (a second "interpreter") requires only a new method with its own switch; no existing code needs touching.