Exhaustive Switch with Sealed Types

Naveen AnamaneniUpdated · 8 min read

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 typeCovered when the cases include
Any type Tdefault, or an unguarded case T t
An enumEvery constant
A sealed interface or classA case for every permitted subtype (each covered recursively)
A boolean, with primitive patternstrue 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

Vehicles.java▶ Try it
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()));
    }
}
Output
sports car
car
truck

Explanation: 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

One case per permitted type; a new type means a compile error

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:

CalcBad.java
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
    };
}
Compiler output
CalcBad.java:9: error: the switch expression does not cover all possible input values
        return switch (e) {
               ^
1 error

The 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:

Terminal
javac *.java
java Area
# add Triangle to Shape, recompile only Shape.java and Main2.java
javac Shape.java Main2.java
java Area
Terminal output
4.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:

Compiler output
Area.java:3: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^
1 error

The 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:

Calculator.java▶ Try it
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));
    }
}
Output
(2 + 3) * 4 = 20
(-7 + 10 * 2) = 13

Explanation:

  • The data: an expression is a tree. (2 + 3) * 4 is a Mul whose left side is an Add and whose right side is a Num.
  • eval: for each kind of node, the switch says how to compute it, calling itself for the children. The recursion stops at Num.
  • 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 the permits list makes both eval and show fail 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

  1. Easy: Add record Sub(Expr left, Expr right) to the calculator. Fix every compile error, then evaluate 10 - 4 * 2.
  2. Harder: Add a third operation, simplify(Expr e), that replaces Mul by Num(0) when either side is Num(0), and Add with Num(0) by the other side. Test it on (5 + 0) * 0 and (5 + 0) * 3.

Quick check: Exhaustive switch

4 questions
1When is a switch over a sealed interface exhaustive without default?
2What happens when you add a new permitted subtype?
3When is MatchException thrown?
4Does case Car c cover subclasses of a non-sealed Car?

Key Takeaways

Was this tutorial helpful?

Lesson 459 of 482