Contents
- Exhaustive Switch on Sealed Types
- Guarded Patterns with when
- Record Patterns inside switch
- null Handling in Pattern Switches
- AST Evaluator — Full Example
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.