Exhaustive Switch with Sealed Types
A switch is exhaustive when every possible value of its selector matches some case. With a sealed interface or class, the compiler knows the complete list of subtypes, so a switch with one case per subtype is exhaustive without a default. That turns the compiler into a checklist: when you add a new subtype, every switch that doesn't handle it stops compiling, showing you exactly what to update. In this lesson, you will learn how the compiler decides exhaustiveness, why leaving out default is often better, what MatchException is, and you will build an expression evaluator with one exhaustive switch per operation.
"Sealed Classes and Interfaces in Java" introduced sealed and permits. This lesson focuses on how they work with switch.
How the Compiler Decides
A switch over a type T is exhaustive if its unguarded cases together cover T:
| Selector type | Covered when the cases include |
|---|---|
Any type T | default, or an unguarded case T t |
| An enum | Every constant |
| A sealed interface or class | A case for every permitted subtype (each covered recursively) |
A boolean, with primitive patterns | true and false (preview; see "Primitive Types in Patterns (Preview)") |
Guarded cases never count (see "Guards in Switch (When Clause)"). A case for a class also covers all its subclasses, which matters for non-sealed subtypes.
Example: An Exhaustive Switch Without Default
public class Vehicles {
sealed interface Vehicle permits Car, Truck { }
static non-sealed class Car implements Vehicle { }
static class SportsCar extends Car { }
static final class Truck implements Vehicle { }
static String kind(Vehicle v) {
return switch (v) {
case SportsCar s -> "sports car";
case Car c -> "car";
case Truck t -> "truck";
};
}
public static void main(String[] args) {
System.out.println(kind(new SportsCar()));
System.out.println(kind(new Car()));
System.out.println(kind(new Truck()));
}
}sports car
car
truckExplanation: Vehicle permits only Car and Truck. Car is non-sealed, so anyone may extend it, but case Car c covers every subclass anyway. case SportsCar s must come before case Car c, or it would be dominated. No default is needed: the compiler can see that every Vehicle is handled.
Why Leaving Out Default Is Safer
Suppose you add a new subtype to a sealed interface. With default, existing switches still compile and silently send the new type to default, which is often wrong. Without default, every switch that misses the new type fails to compile:
sealed interface Expr permits Num, Add, Mul, Neg { }
...
static int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
case Add a -> eval(a.left()) + eval(a.right());
case Mul m -> eval(m.left()) * eval(m.right());
// Neg was just added and isn't handled yet
};
}CalcBad.java:9: error: the switch expression does not cover all possible input values
return switch (e) {
^
1 errorThe same check works for enums: a switch expression over a three-constant enum with only two cases fails with the same error.
MatchException: When the Classes Change After Compiling
Exhaustiveness is checked at compile time. If a sealed interface gains a new subtype and is recompiled without recompiling the switch, the old switch can meet a value it doesn't know. Instead of quietly doing nothing, Java throws MatchException. Here Area was compiled when Shape permitted Circle and Square; then Triangle was added and only Shape.java and Main2.java were recompiled:
javac *.java
java Area
# add Triangle to Shape, recompile only Shape.java and Main2.java
javac Shape.java Main2.java
java Area4.0
9.0
4.0
Exception in thread "main" java.lang.MatchException
at Area.area(Area.java:3)
at Area.main(Area.java:10)Recompiling everything then reports the missing case at compile time:
Area.java:3: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 errorThe compiler adds this hidden "no case matched" path to every exhaustive switch without default. It shows up only when classes are compiled separately, such as a library that is updated without rebuilding the code that uses it.
Checkpoint: An Expression Evaluator
Let's build a small calculator. Each kind of expression is a record implementing a sealed interface, and each operation is one exhaustive switch:
public class Calculator {
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 operand) implements Expr { }
static int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
case Add a -> eval(a.left()) + eval(a.right());
case Mul m -> eval(m.left()) * eval(m.right());
case Neg n -> -eval(n.operand());
};
}
static String show(Expr e) {
return switch (e) {
case Num n -> String.valueOf(n.value());
case Add a -> "(" + show(a.left()) + " + " + show(a.right()) + ")";
case Mul m -> show(m.left()) + " * " + show(m.right());
case Neg n -> "-" + show(n.operand());
};
}
public static void main(String[] args) {
// (2 + 3) * 4
Expr total = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
System.out.println(show(total) + " = " + eval(total));
// -(7) + 10 * 2
Expr other = new Add(new Neg(new Num(7)),
new Mul(new Num(10), new Num(2)));
System.out.println(show(other) + " = " + eval(other));
}
}(2 + 3) * 4 = 20
(-7 + 10 * 2) = 13Explanation:
- The data: an expression is a tree.
(2 + 3) * 4is aMulwhose left side is anAddand whose right side is aNum. eval: for each kind of node, the switch says how to compute it, calling itself for the children. The recursion stops atNum.show: a second, independent operation over the same types, with no change to the records.- Exhaustiveness: neither switch has
default. Adding, say,record Div(Expr left, Expr right)to thepermitslist makes bothevalandshowfail to compile until they handle it.
This style, data in records and operations in switches, is often called data-oriented programming. It is the opposite trade-off from putting an eval() method in each class: adding an operation is easy (write one more switch), while adding a type means updating every switch, which the compiler helps you find. The next lesson, "Record Patterns in Java", makes these switches even shorter.
Common Mistakes
Adding default to a switch over a sealed type. It compiles, but hides the error you want when a new subtype appears.
Using guarded cases only. They don't count toward exhaustiveness; add an unguarded case per subtype.
Recompiling only part of a project. That's how MatchException happens; rebuild everything that switches over the changed type.
Interview Corner
Why can a switch over a sealed interface omit default?
Because the compiler knows every permitted subtype. If the switch has an unguarded case for each one, it covers all possible values and is exhaustive. Omitting default is safer: when a new subtype is added to the permits clause, every switch that doesn't handle it fails to compile. If code compiled against the old hierarchy meets a new subtype at run time, the switch throws MatchException.
What is MatchException?
A runtime exception (Java 21) thrown when an exhaustive switch or record pattern finds no matching case at run time. It normally happens only through separate compilation, for example when a sealed hierarchy gains a subtype and the switch's class wasn't recompiled. It is also thrown if a record accessor called during record pattern matching throws an exception, wrapping that exception.
Practice
- Easy: Add
record Sub(Expr left, Expr right)to the calculator. Fix every compile error, then evaluate10 - 4 * 2. - Harder: Add a third operation,
simplify(Expr e), that replacesMulbyNum(0)when either side isNum(0), andAddwithNum(0)by the other side. Test it on(5 + 0) * 0and(5 + 0) * 3.
Key Takeaways
Was this tutorial helpful?