Topic 11.2
Pattern Matching for switch
In one line
From Java 21, switch can test the type and shape of any object, not only constants: case Circle c -> ..., with when guards, case null, and a compiler that checks every possible type is handled. With sealed types it replaces long if/instanceof chains with one exhaustive, checked expression.
Think of it like this
A post-office sorting machine. Letters go to one chute, parcels to another, and parcels heavier than 10 kg go to a special chute first. The machine was built knowing every kind of item the post office accepts, so nothing can fall on the floor. If the post office starts accepting a new kind of item, the machine must be updated before it runs. A pattern switch is that sorting machine for objects.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Selector
- The value inside
switch (...)that the cases are tested against. - Case label
- What follows
case: a constant like3, a pattern likeString s, ornull. - Guard
- A
whencondition after a pattern, likecase Integer i when i > 0. The case matches only if the condition is also true. - Dominance
- When an earlier case always matches everything a later case would match, so the later case could never run. The compiler reports it as an error.
- Exhaustive
- Covering every possible value. An exhaustive switch can never reach the end without a matching case.
- Sealed type
- A class or interface that lists exactly which classes may extend or implement it (
sealed ... permits A, B), so the compiler knows all its subtypes. - MatchException
- An exception (Java 21) thrown when an exhaustive switch meets a value none of its cases match, which can only happen if classes changed after compilation.
- yield
- The keyword that gives a value back from a block
{ ... }inside a switch expression.
Step by step
01From an if-chain to a switch
Before Java 21, code that behaves differently for different types was a chain of if (o instanceof ...) else if .... Each branch is fine on its own, but nothing checks that the chain is complete, and the order is easy to get wrong.
A pattern switch says the same thing in one expression, each case a type pattern. Because it is an expression, it must produce a value for every input, and the compiler checks that.
// before: an if-chain nobody checks for completeness
static String describeOld(Object o) {
if (o instanceof Integer i) return "a number " + i;
else if (o instanceof String s) return "a string of length " + s.length();
else return "something else";
}
// Java 21: one switch expression
static String describe(Object o) {
return switch (o) {
case Integer i -> "a number " + i;
case String s -> "a string of length " + s.length();
default -> "something else";
};
}02Guards with when
A guard refines a pattern: case Integer i when i < 0. The pattern is checked first; only if it matches is the guard evaluated, and it can use the binding i. If the guard is false, matching continues with the next case.
So the usual layout is: guarded cases for a type first, then the unguarded case for the same type as the catch-all for that type.
return switch (o) {
case Integer i when i < 0 -> "a negative number " + i; // checked first
case Integer i -> "a number " + i; // every other Integer
case String s when s.isBlank() -> "a blank string";
case String s -> "a string of length " + s.length();
default -> "something else";
};03Dominance: the compiler checks your order
Because cases are tried in order, a general case placed before a specific one would hide it forever. Every String is a CharSequence, so in the switch below case String s could never run. Java treats that as a mistake, not a warning.
The same applies to a guarded case placed after the unguarded case for the same type, and to a constant case (like case 0) placed after case Integer i.
04null gets its own case
Historically switch on a null throws NullPointerException, and that is still the default. A pattern switch may add case null -> ... to handle it explicitly, or combine it with the default as case null, default -> ....
So with pattern switches, the rule is: **no case null, then null throws**. That keeps old code's behaviour and makes null handling visible where you want it.
static String kind(Object o) {
return switch (o) {
case String s -> "text";
case null, default -> "not text (or null)";
};
}05Exhaustive over a sealed type: no default needed
When the selector is a sealed interface, the compiler reads its permits list. A switch with a case for each permitted subtype is exhaustive, so it needs no default. Leave one out and you get the switch expression does not cover all possible input values.
This is the real payoff. With a default, adding a new subtype compiles silently and the new type falls into the default branch. Without one, every switch that needs to handle the new type becomes a compile error that points you to it.
sealed interface Shape permits Circle, Square, Rect { }
record Circle(double r) implements Shape { }
record Square(double side) implements Shape { }
record Rect(double w, double h) implements Shape { }
static double area(Shape s) {
return switch (s) { // no default: all three are covered
case Circle c -> Math.PI * c.r() * c.r();
case Square q -> q.side() * q.side();
case Rect r -> r.w() * r.h();
};
}06Statements, blocks and fall-through
Pattern cases also work in switch statements, and a statement that uses patterns or case null must be exhaustive too (old-style statements over int or String still don't have to be).
A case can run a block; in a switch expression the block returns its value with yield. With the old colon form (case String s:) you can't fall through into a case that declares a binding, because the binding would be unassigned: javac reports illegal fall-through to a pattern.
Enum constants can be used qualified in a pattern switch over a sealed interface, for example case Coin.ONE -> 1; when Coin is an enum that implements the sealed interface.
07What happens at run time
A pattern switch compiles to a null check, an invokedynamic call to SwitchBootstraps.typeSwitch that finds the index of the first matching case, then a plain lookupswitch jumping to that case's code, where a checkcast fills the binding. The bootstrap builds the type tests once, at the first call.
If none matches (only possible when classes changed after compilation), the compiler-generated default throws MatchException. Here a new Triangle subtype was added and only Shape.java and Factory.java were recompiled:
Try it yourself
- 1
Reorder and read the error
In the first example, move
case Integer i -> ...abovecase Integer i when i < 0 -> .... Predict the compiler's message, compile, and check. Then put it back. - 2
Remove case null
Delete the
case nullline fromdescribe. Predict what happens for the last input. Run it and read the stack trace. - 3
Grow the sealed type
In the shapes example, add
record Triangle(double b, double h) implements Shape { }and add it topermits. Compile without touching the switches. Count the errors and fix each one.
Code & diagrams
Expected output
a number 42
a negative number -7
a string of length 4
a blank string
an int array of 3
something else: Double
nothing at allDelete the `case Square q` line in area() and compile: the error lists the switch, not a runtime bug.
Expected output
Circle[r=1.0] area=3.14 circle
Square[side=3.0] area=9.0 square
Rect[w=2.0, h=5.0] area=10.0 rect
Rect[w=4.0, h=4.0] area=16.0 rect that is really a square
Circle[r=0.0] area=0.0 a dotExpected output
M no size
classic(null) threw NullPointerException
log text: started
log number: 3
log otherExpected output
52
text / not text (or null) / not text (or null)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Put a general case before a specific one
Write case CharSequence cs -> "chars"; above case String s -> "string";.
Break #2
Forget a subtype of a sealed interface
In a switch over sealed interface Shape permits Circle, Square, write only case Circle c -> ....
Break #3
Compile pattern switches for an old release
Compile a switch with case String s -> using javac --release 17.
Myth vs fact
Myth
default catches null.
Fact
It doesn't. Without case null, a null selector throws NullPointerException, even in a pattern switch with default. Write case null, default to send null to the default branch.
Myth
A pattern switch is slow because it tests types one by one.
Fact
It compiles to an invokedynamic type switch built once at first call, plus a jump table. For a handful of cases it is comparable to an if/instanceof chain, and the JIT optimises both.
Myth
Exhaustive switches over sealed types are fragile because new subtypes break them.
Fact
That compile error is the point: it lists every place that must decide how to handle the new subtype. A default hides those places.
Myth
Pattern switches only work on sealed types.
Fact
They work on any reference type. Over a non-sealed type such as Object, you need a default (or a total pattern) for exhaustiveness.
Interview problem
The problem
Notification router
Your service sends three kinds of notifications: email, SMS and push. Each has different fields and a different delivery cost. Design the types and a cost function so that adding a fourth channel later can't be forgotten anywhere in the codebase.
You're given
- Email has an address and a size in KB; cost is 1 paisa per 100 KB, minimum 1.
- SMS has a phone number and a text; cost is 15 paise per 160-character segment.
- Push has a device token; cost is 0, unless the token is blank (invalid), which must be rejected.
The interviewer follows up
Why not an enum Channel { EMAIL, SMS, PUSH }?
What happens if an old compiled service receives the new record at run time?
When it breaks
A default branch hides a new subtype
What you see
A team adds Refund to a payment hierarchy. A fee switch had default -> 0, so refunds are silently charged no fee and the reconciliation report is off for weeks, with no compile error and no exception.
Fix & prevent
Seal the hierarchy and remove default from switches over it, so new subtypes cause compile errors. Keep default only for open types like Object.
Partial redeploy after changing a sealed hierarchy
What you see
A shared library adds a subtype and one service is redeployed with it while another service's switch was compiled against the old version. The old service throws java.lang.MatchException on the first message carrying the new type.
Fix & prevent
Deploy consumers before producers start sending new types, version message schemas, and catch the exception at the message boundary to dead-letter rather than crash.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Exhaustiveness is checked at compile time but enforced again at run time: the compiler adds a synthetic default that throws
MatchException(Java 21) for pattern switches. This protects against separate compilation, where a sealed hierarchy gains a subtype but the switch's class wasn't recompiled. - ▸
SwitchBootstraps.typeSwitchreceives the case labels as bootstrap arguments (classes, and constants such as strings or enum descriptors) and a restart index. When a guard fails, the generated code calls the bootstrap again with the next index, so matching continues from the following case without retesting earlier ones. - ▸
Pattern switch is Java's answer to the Visitor pattern: instead of adding
accept/visitmethods to every class, you write one exhaustive switch per operation. Visitor still wins when the set of operations is closed and the types are open; sealed types plus switches win when the types are closed and operations grow (see the System Design course's behavioural patterns). - ▸
Primitive types in patterns (
case int i,switchonlong,float,double,boolean) are a separate feature: preview in Java 23, 24 and 25 (JEP 455, 488, 507), not yet standard as of Java 25.
Remember this
- 1
A classic
switch(Topic 2.3) and a switch expression (Topic 2.4) compare a value against constants: numbers, characters, strings and enum names. Pattern matching forswitchlets acasehold a pattern instead:case Integer i -> ...matches anyIntegerand binds it toi, exactly likeo instanceof Integer iin Topic 11.1. The selector can be any reference type. It was previewed in Java 17 to 20 and became standard in Java 21 (JEP 441). - 2
A
casecan add a guard withwhen:case Integer i when i < 0 -> "negative". The case matches only if the pattern matches and the boolean guard is true. Guards replace nestedifstatements inside a case and can use the binding. - 3
Order matters. Cases are tried top to bottom, and the first match wins. The compiler rejects a case that can never be reached because an earlier one always catches its values:
case CharSequence csbeforecase String sgivesthis case label is dominated by a preceding case label. Put specific cases (and guarded cases) before general ones. - 4
**
nullhandling.** A classicswitchthrowsNullPointerExceptionwhen the selector isnull. A pattern switch does the same unless it has acase null(orcase null, default), in which casenullgoes there.defaultalone never catchesnull. - 5
Exhaustiveness. A pattern
switch(expression or statement) must cover every possible value of the selector's type. OverObjectthat means adefault(or acase Object o). Over a sealed interface (Topic 5.11) the compiler knows every permitted subtype, so covering each one is enough and nodefaultis needed. Then, when someone adds a new subtype, every switch that forgot it fails to compile. That turns a whole class of runtime bugs into compile errors. - 6
At run time,
javacturns a pattern switch into aninvokedynamiccall tojava.lang.runtime.SwitchBootstraps.typeSwitch, which returns the index of the first matching case, followed by an ordinarylookupswitch. If a sealed hierarchy changed after your code was compiled and an unknown subtype arrives, the hidden default throwsMatchExceptioninstead of silently doing nothing. Topic 11.3 adds record patterns, which take each case apart in the same step.
Explain it without notes
What does pattern matching for switch add over a classic switch, and in which version did it become standard?
What is a guard, and how does it interact with case order?
Explain dominance with an example.
How does a pattern switch treat null?
Why is an exhaustive switch over a sealed interface without default better than one with default?
Practice
Write static String classify(Object o) that returns "null", "even N"/"odd N" for Integers, "empty text" or "text 'S'" for Strings, and "other X" otherwise. Test with 4, 7, "", "dosa", 1.5, null.
Model sealed interface Payment permits Card, Upi, Cash as records with an amount in paise (Card also has boolean international). Write feePaise with no default: international cards 3%, domestic cards 2%, UPI and cash free.
Write static String toJson(Object o) with a pattern switch that handles null, String (quoted), Number, Boolean, and List<?> (recursively, using a block and yield), and throws IllegalArgumentException otherwise.
Trade-offs
- ↔
Exhaustive switches over sealed types catch forgotten cases at compile time, but each new subtype forces edits in every switch. That's right for a closed domain (payment kinds, AST nodes) and wrong for a plugin-style open set, where polymorphic methods fit better.
- ↔
Guards keep related logic in one place, but long guards turn a switch into hidden business rules. Move complex conditions into well-named methods:
case Order o when o.isOverdue() -> .... - ↔
Pattern switches need Java 21. Libraries that support older runtimes must keep
if/instanceofchains or multi-release JARs.
Done when you can
Done when you can rewrite an
instanceofchain as a pattern switch.Done when you can order cases correctly and explain dominance errors.
Done when you know how
nullbehaves with and withoutcase null.Done when you can write an exhaustive switch over a sealed interface without
default.Done when you can explain
MatchExceptionand when it is thrown.