Java 21 lets a switch match on types, not just constants. Combined with
sealed types, it turns a chain of instanceof
checks into something the compiler can prove is complete.
The chain it replaces
class Demo {
String oldWay(Object value) {
if (value instanceof Integer i) return "int: " + i;
if (value instanceof String s) return "text: " + s.length();
if (value instanceof List<?> l) return "list of " + l.size();
return "unknown";
}
String newWay(Object value) {
return switch (value) {
case Integer i -> "int: " + i;
case String s -> "text: " + s.length();
case List<?> l -> "list of " + l.size();
default -> "unknown";
};
}
}
Each case tests a type and binds a variable of that type, the same as
instanceof pattern matching. The gain over the
if chain is that it is an expression — one return, and the compiler checks
that every path produces a value.
Guards with when
class Demo {
String classify(Object value) {
return switch (value) {
case Integer i when i < 0 -> "negative";
case Integer i when i == 0 -> "zero";
case Integer i -> "positive: " + i;
case String s when s.isBlank() -> "blank text";
case String s -> "text: " + s;
default -> "other";
};
}
}
Order matters, and the compiler enforces it. A guarded case must come before the
unguarded case for the same type — put case Integer i first and the guarded ones below
are unreachable, which is a compile error rather than a silent bug.
when is a contextual keyword, so any existing variable called when keeps
working.
case null
class Demo {
String describe(Object value) {
return switch (value) {
case null -> "nothing"; // now legal
case String s -> "text: " + s;
default -> "something";
};
}
String stillThrows(Object value) {
return switch (value) {
case String s -> "text";
default -> "other"; // does NOT catch null
};
}
}
This fixes a genuine wart. Before Java 21, switch on a null reference threw
NullPointerException — including when there was a default, which surprised
everyone. You can now handle null explicitly.
The old behaviour is preserved when you do not: a switch with no case null still
throws. That is deliberate backwards compatibility, and it means adding case null is
opt-in. case null, default -> ... combines the two when you want them to behave the
same.
Exhaustiveness with sealed types
This is where the feature earns its place:
sealed interface Payment permits Card, BankTransfer, StoreCredit { }
record Card(String last4) implements Payment { }
record BankTransfer(String iban) implements Payment { }
record StoreCredit(double amount) implements Payment { }
class Processor {
String describe(Payment payment) {
return switch (payment) { // no default needed
case Card c -> "card ending " + c.last4();
case BankTransfer b -> "transfer from " + b.iban();
case StoreCredit s -> "credit of " + s.amount();
};
}
}
Because Payment is sealed, the compiler knows the complete list of subtypes and can
verify you covered them. Add a fourth payment kind and every switch like this stops compiling — you
get the list of places to update instead of a silent default swallowing the new case.
Do not add a default to an exhaustive switch. It compiles, and it
throws away the guarantee.
Record patterns
Types and their contents can be matched together. Record patterns has the detail; the shape is worth seeing here because it is where the feature becomes genuinely expressive:
sealed interface Shape permits Circle, Rectangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
class Geometry {
String describe(Shape shape) {
return switch (shape) {
case Circle(double r) when r > 100 -> "big circle";
case Circle(double r) -> "circle of radius " + r;
case Rectangle(double w, double h) when w == h -> "square of " + w;
case Rectangle(double w, double h) -> w + " by " + h;
};
}
}
Dominance, and the error you will meet
The compiler rejects any case that can never be reached because an earlier one already covers it. It calls this dominance, and the message names it:
class Demo {
String broken(Object value) {
return switch (value) {
case Object o -> "anything";
// case String s -> "text"; // error: this case label is dominated
};
// Note: no `default` here either. `case Object o` already matches
// everything, and a switch cannot have both an unconditional pattern
// and a default label — that is a second, separate compile error.
}
String fixed(Object value) {
return switch (value) {
case String s -> "text"; // most specific first
case Object o -> "anything";
};
}
}
The rule is the same one that governs catch blocks: specific before general, or the
general one swallows everything. It applies to guards too — an unguarded case dominates every guarded
case for the same type, which is why the guarded ones must come first.
This is a good error to get. The equivalent mistake in an if/else if
chain compiles silently and the branch simply never runs.
What it does not replace
The same caution as the instanceof post: if you own the types and the behaviour
belongs to them, a method on the interface beats a switch. Adding a shape then requires no edit at
all, whereas the switch above needs one — even though the compiler will remind you.
The switch is the better answer when the behaviour belongs to the caller rather than the type (formatting for one particular screen), when the hierarchy is not yours to change, or when you need the exhaustiveness check as documentation of a decision table.
There is also a legitimate objection that type-switching is object-orientation turned inside out. It is — and sealed types are what make it safe, because the set is closed and checked. Type-switching over an open hierarchy still deserves the suspicion it always did.
Next
Record Patterns is next — destructuring a record into its components as part of the match.