Exhaustive Java Pattern Switches: Why Omitting default Is Safer
Use sealed types and record patterns to make Java switches exhaustive, preserve compiler checks during evolution, and handle null and binary changes.
When a Java switch covers every permitted subtype of a sealed hierarchy, omit default. The compiler still accepts the switch as exhaustive, and a later source change that adds a permitted subtype turns every recompiled switch into a useful compile error. A broad default would silently absorb the new domain case and hide the place that needs a deliberate decision.
Pattern matching for switch and record patterns became final in JDK 21. This article uses their final syntax and targets JDK 25; none of the examples require preview features.
Let the sealed hierarchy define the cases
sealed interface Payment permits Card, Transfer, Cash {}
record Card(String lastFour) implements Payment {}
record Transfer(String bank) implements Payment {}
record Cash() implements Payment {}
public class PaymentLabel {
static String label(Payment payment) {
return switch (payment) {
case Card(var lastFour) -> "card …" + lastFour;
case Transfer(var bank) -> "transfer from " + bank;
case Cash() -> "cash";
};
}
public static void main(String[] args) {
System.out.println(label(new Card("4242")));
}
}
Compile and run it with JDK 21 or later:
javac PaymentLabel.java
java PaymentLabel
The output is card …4242. The compiler knows that direct implementors of Payment must be among its permitted types, and each of those records is final. The Java Language Specification’s exhaustiveness rules define when a set of type and record patterns covers a selector type.
Now add record Voucher(String code) implements Payment {} and add Voucher to the permits clause without adding a switch case. Recompilation fails because the switch no longer covers Payment. That failure is the point: the new business state has reached every decision site that needs review.
A default label opts out of that source check
This version compiles after Voucher is added, but it may classify vouchers incorrectly:
static String label(Payment payment) {
return switch (payment) {
case Card(var lastFour) -> "card …" + lastFour;
case Transfer(var bank) -> "transfer from " + bank;
case Cash() -> "cash";
default -> "other";
};
}
Use default when the selector is intentionally open-ended, such as Object or a non-sealed extension point, or when all unknown values genuinely share a stable policy. Do not add it merely to silence an exhaustiveness error. For a closed domain, list the known alternatives.
The same reasoning applies to enums. Listing every enum constant without default lets recompilation identify switches that need attention when a constant is added.
Guards do not usually contribute to coverage
A guarded label restricts a pattern to values for which its when expression is true. Except for the constant expression true, a guard is not used to prove exhaustiveness. Put specific guarded cases before an unguarded case of the same type:
static String amount(Payment payment) {
return switch (payment) {
case Card(var digits) when digits.startsWith("4") -> "Visa card";
case Card(var digits) -> "card";
case Transfer(var bank) -> "bank transfer";
case Cash() -> "cash";
};
}
Order also controls dominance. A broad case Payment p before case Card c makes the narrower label unreachable and is a compile-time error. The JDK 25 pattern-switch guide documents both guard behavior and dominance checks.
Record patterns can prove nested coverage
Exhaustiveness can flow through a record component:
record Receipt(Payment payment) {}
static String receiptLabel(Receipt receipt) {
return switch (receipt) {
case Receipt(Card(var digits)) -> "card " + digits;
case Receipt(Transfer(var bank)) -> "transfer " + bank;
case Receipt(Cash()) -> "cash";
};
}
Those three patterns provide type coverage for Receipt, but no nested record pattern matches a null component. Calling receiptLabel(new Receipt(null)) therefore reaches no label and throws MatchException. The compiler combines compatible record patterns when determining type coverage; that analysis does not turn a nested null into one of the permitted subtypes. It also does not assume that arbitrary combinations cover a product: cases for (Card, Card), (Transfer, Transfer), and (Cash, Cash) do not cover a pair containing (Card, Cash).
A record accessor that throws while a record pattern is deconstructing its value causes pattern matching to throw MatchException with the accessor failure as its cause. Records normally have simple component accessors, but an explicitly overridden accessor can create this failure mode.
Exhaustiveness does not include null
Reference selector types may still hold null. An exhaustive type-pattern switch without case null throws NullPointerException when its selector is null; case Payment p does not match null.
Choose the null policy explicitly at the boundary:
static String nullableLabel(Payment payment) {
return switch (payment) {
case null -> "not supplied";
case Card(var digits) -> "card …" + digits;
case Transfer(var bank) -> "transfer from " + bank;
case Cash() -> "cash";
};
}
If null is a programmer error, omit the case and let the switch fail promptly. Do not combine domain evolution and null handling in a catch-all default.
Binary evolution still needs deployment discipline
Adding a permitted subtype is binary compatible: an old class containing an exhaustive switch still links against the new sealed hierarchy. It cannot, however, contain bytecode for a case that did not exist when it was compiled. If that old switch receives the new subtype, it throws MatchException.
The JLS binary-compatibility rules for sealed classes call this a migration incompatibility. Omitting default improves source evolution when consumers recompile; it cannot update already deployed binaries. Recompile and test all switch consumers when expanding a sealed hierarchy, and deploy compatible producer and consumer versions together when the new subtype may cross their boundary.