skip to content
everything.modern.patternMatching

pattern matching

instanceof patterns, switch patterns, guards and dominance.

chapter
chapter 08.05
reading time
8 min read
java 21+

a pattern tests a value and pulls it apart in one go. o instanceof String s checks the type and hands you s, already cast, in exactly the places where the test is known to be true (flow scoping). final in java 16.

in a switch java 21+, every case can be a pattern: case Integer i when i > 0. you get guards (when), case null, and two compile-time checks that catch real bugs: dominance (a case that can never match) and exhaustiveness (an input that matches nothing).

the picture

you know the instanceof dance? check the type, then cast to the type you just checked, then name it, three times saying the same thing:

don'ttest, cast, name: three steps, one idea
java
if (o instanceof String) {
    String s = (String) o;
    IO.println(s.strip().length());
}
doa pattern: test and bind at once
java
if (o instanceof String s) {
    IO.println(s.strip().length());
}

the cast was never the point, it was noise you had to write because the compiler didn't connect the check to the next line. a pattern makes the connection.

think of the sorting belt at a post office. the old way: a worker checks if a parcel is a letter, shouts "letter!", and a second worker picks it up and puts a "letter" sticker on it. the new way: one scanner that checks the shape and slaps the sticker on in the same motion. and the sticker only exists on parcels that passed.

instanceof patterns and flow scoping

the variable in a pattern (s) isn't scoped to a block like a normal local. it exists wherever javac can prove the match succeeded, and nowhere else. that sounds abstract until you see the shapes:

shape =
Main.java
  1. 1if (!(o instanceof String s)) {
  2. 2 return 0;
  3. 3}
  4. 4return s.length(); // s is here!
compiles

the clever one. if the test failed, the method returned. so every line after the if knows the test passed, and s is in scope for the rest of the method. great for guard clauses.

pick a shape. green lines are where s is in scope. a red line uses s where the match isn't guaranteed, and javac 25 rejects it. the "if (!...) return" shape is the one worth remembering.
Main.java
void main() {
    Object o = "  hello  ";
    if (o instanceof String) {
        String s = (String) o;
        IO.println(s.strip().length());
    }
    if (o instanceof String s) {
        IO.println(s.strip().length());
    }
    if (o instanceof String s && !s.isBlank()) {
        s = s.strip();                     // pattern variables aren't final
        IO.println(s);
    }
}
output
5
5
hello

patterns in switch

instanceof tests one pattern. a switch tests a list of them, top to bottom, and the first one that matches wins. each case can add a guard with when, a boolean that has to be true too:

Main.java
static String describe(Object o) {
    return switch (o) {
        case null                      -> "null";
        case Integer i when i < 0      -> "negative int " + i;
        case Integer i                 -> "int " + i;
        case String s when s.isBlank() -> "blank string";
        case String s                  -> "string of length " + s.length();
        case int[] a                   -> "int[] of " + a.length;
        default                        -> "something else: " + o;
    };
}
 
void main() {
    Object[] inputs = { null, -3, 42, " ", "hi", new int[] {1, 2, 3}, 3.14, List.of(1) };
    for (Object o : inputs) IO.println(describe(o));
}
output
null
negative int -3
int 42
blank string
string of length 2
int[] of 3
something else: 3.14
something else: [1]
describe(
case null
Main.java
  1. 1static String describe(Object o) {
  2. 2 return switch (o) {
  3. 3 case null -> "null";✗ not null
  4. 4 case Integer i when i < 0 -> "negative int " + i;← match
  5. 5 case Integer i -> "int " + i;
  6. 6 case String s when s.isBlank() -> "blank string";
  7. 7 case String s -> "string of length " + s.length();
  8. 8 case int[] a -> "int[] of " + a.length;
  9. 9 default -> "something else: " + o;
  10. 10 };
  11. 11}
$describe(-3)
negative int -3

tested 2 cases, top to bottom. a label fails either on its type test or on its when guard, and the next one gets a go. the first match wins, and everything below it is never considered.

pick an input and watch the cases get tried in order: ✗ fails its type test or its guard, green is the first match, everything below it is skipped. then delete the case null line and pass null. outputs are real jdk 25.

(that's the mental model. what javac actually emits is a single invokedynamic to SwitchBootstraps.typeSwitch, which works out the matching case index in one call instead of a chain of instanceofs.)

dominance

because the first match wins, order matters, and javac checks it. if a case can never be reached because an earlier one always matches first, the later case is dominated, and that's a compile error, not a warning:

supertype first
case CharSequence cs then case String s: every String is a CharSequence, so the second case is dead.
unguarded first
case Integer i then case Integer i when i > 0: the guard can never get a look in.
pattern before constant
case Integer i then case 42 (switching on an Integer): 42 already matched.
default first
default then case String s: with patterns in play, default has to come after them.

all four get the same message from javac 25:

text
error: this case label is dominated by a preceding case label

the fix is always to go from specific to general: constants, then guarded patterns, then plain patterns, then the catch-all.

predict the output
java
Object o = 5;
String r = switch (o) {
    case Integer i when i > 0 -> "positive";
    case Number n -> "number";
    case Integer i -> "int";
    default -> "other";
};
IO.println(r);

null and exhaustiveness

two things the old switch did that pattern switches keep, and one they add:

  • null still throws, unless you ask. a switch on null throws NullPointerException before testing any case, exactly like before java 21. to handle it, add case null, or fold it into the catch-all with case null, default ->.
  • pattern switches must be exhaustive. a switch expression always had to cover every input; now a switch statement that uses patterns does too. over Object that means a default (or a total pattern like case Object x). miss it and javac says the switch statement "does not cover all possible input values".
  • sealed types make exhaustiveness useful. over a sealed interface, a case per permitted subtype is enough, no default, and adding a subtype later turns every incomplete switch into a compile error. that's the whole story in sealed types, and it gets better with record patterns.
quick check
which case order compiles?
keylines
  • a pattern tests and binds at once: o instanceof String s.
  • flow scoping: the variable exists exactly where the match is guaranteed, even after an early return.
  • in switch, cases are tried top to bottom, first match wins, when adds a condition.
  • a case that can never match is a compile error (dominance). go specific to general.
  • null throws unless there's a case null. pattern switches must be exhaustive.