Why this matters
- The test-then-cast pair appears in almost every
equalsmethod and every piece of code handling a heterogeneous input, and it was always redundant. - The scoping rules for the bound variable are precise and occasionally surprising, which makes them a reliable interview question.
- This is the first step of a larger feature: the same pattern syntax extends to
switchand to record deconstruction.
Before and after
// Java 8 — the type is written twice and the cast can drift from the test
if (object instanceof String) {
String text = (String) object;
if (text.length() > 3) {
System.out.println(text.toUpperCase());
}
}
// Java 16+ — one test, one binding, no cast
if (object instanceof String text && text.length() > 3) {
System.out.println(text.toUpperCase());
}
s is in scope only where the test passed.
The variable text is the pattern variable. It exists only where the pattern is known to have matched, and
the compiler enforces that.
Where the pattern variable is in scope
This is the part worth reading carefully.
// In the body of the if, when the pattern matched
if (object instanceof String text) {
System.out.println(text.length()); // in scope
}
// System.out.println(text); // compile error — out of scope here
// After && — because reaching the right operand means the pattern matched
if (object instanceof String text && text.isBlank()) { }
// After || — illegal, because the right operand runs when the pattern did NOT match
// if (object instanceof String text || text.isBlank()) { } // compile error
// In the else branch of a negated pattern — the useful guard-clause form
if (!(object instanceof String text)) {
return 0; // text is not in scope here
}
return text.length(); // but it IS in scope for the rest of the method
// Scope extends to the end of the enclosing block after an early return
void process(Object input) {
if (!(input instanceof Order order)) {
throw new IllegalArgumentException("not an order");
}
// order is usable for the whole rest of the method — no nesting, no cast
audit(order);
ship(order);
}
That guard-clause shape is the single biggest readability win in practice, because it removes a level of nesting from every method that validates its input.
Guarded patterns
A pattern combined with a condition using && is often called a guarded pattern. It collapses nested
conditionals into one line.
// Nested
if (object instanceof Order) {
Order order = (Order) object;
if (order.total().compareTo(THRESHOLD) > 0) {
if (order.customer() != null) {
flagForReview(order);
}
}
}
// Flat
if (object instanceof Order order
&& order.total().compareTo(THRESHOLD) > 0
&& order.customer() != null) {
flagForReview(order);
}
equals, rewritten
Almost every hand-written equals becomes shorter.
// The traditional form
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point other = (Point) o;
return x == other.x && y == other.y;
}
// With a type pattern
@Override
public boolean equals(Object o) {
return o instanceof Point other && x == other.x && y == other.y;
}
instanceof also returns false for null, which means the null check is already handled — one more line the
pattern form removes.
Object nothing = null;
System.out.println(nothing instanceof String text); // false, no exception
Combining with sealed types
sealed interface Event permits Click, KeyPress, Scroll { }
record Click(int x, int y) implements Event { }
record KeyPress(char key) implements Event { }
record Scroll(int delta) implements Event { }
String describe(Event event) {
if (event instanceof Click click) return "click at " + click.x() + "," + click.y();
if (event instanceof KeyPress press) return "key " + press.key();
if (event instanceof Scroll scroll) return "scroll " + scroll.delta();
throw new IllegalStateException(); // still needed with if-chains
}
A chain of if statements cannot be checked for exhaustiveness, so the unreachable throw remains. Moving to
switch removes it — which is exactly why pattern matching for switch followed.
String describe(Event event) {
return switch (event) {
case Click click -> "click at " + click.x() + "," + click.y();
case KeyPress press -> "key " + press.key();
case Scroll scroll -> "scroll " + scroll.delta();
}; // exhaustive — no default, no throw
}
| Aspect | Pattern in if | Pattern in switch |
|---|---|---|
| Available since | Java 16 | Java 21 |
| Exhaustiveness | Not checked — needs a final throw | Checked against a sealed hierarchy |
| Best for | One or two cases, guard clauses, equals | Three or more alternatives |
| Null | Falls through as false | Can be matched with case null |
| Shape | Imperative — do something and return | Expression — produce a value |
Available since
Pattern in ifJava 16Pattern in switchJava 21Exhaustiveness
Pattern in ifNot checked — needs a final throwPattern in switchChecked against a sealed hierarchyBest for
Pattern in ifOne or two cases, guard clauses, equalsPattern in switchThree or more alternativesNull
Pattern in ifFalls through as falsePattern in switchCan be matched with case nullShape
Pattern in ifImperative — do something and returnPattern in switchExpression — produce a value
Use if for a guard clause or a single test; use switch once you are dispatching over a hierarchy.
Keeping it readable
// Clear: the name says what was matched
if (payload instanceof JsonObject json && json.has("id")) { }
// Unclear: a short meaningless name loses the benefit
if (payload instanceof JsonObject j && j.has("id")) { }
Common misreadings
- "The pattern variable is visible after the
if." Only where the compiler can prove the match succeeded. - "It works after
||." It does not, because the right operand runs when the match failed. - "You still need a null check."
instanceofis alreadyfalsefor null. - "It matches the exact class." It matches any subtype, so it is not a drop-in replacement for
getClass()in an extensible class'sequals. - "An
if-chain over a sealed type is exhaustive." Onlyswitchgets exhaustiveness checking. - "The pattern variable is final." It is effectively final by convention but can be reassigned; doing so is confusing and should be avoided.
- "This is just syntax sugar." It is, and that is the point: less code that can drift out of sync.
Quick recall
Everything you need if you only revisit this box.
- A type pattern combines test, cast and binding:
if (o instanceof String s). - The pattern variable is in scope exactly where the match is provably true — in the
ifbody, after&&, and after an early return in a negated test. - It is not in scope after
||. instanceofisfalsefor null, so no extra null check is needed.- The negated guard-clause form (
if (!(o instanceof T t)) return;) extends the binding to the rest of the method and removes nesting. - A guarded pattern adds conditions with
&&, flattening nestedifblocks. equalsshrinks to one line — but keepgetClass()for non-final classes, since patterns match subtypes.if-chains are not exhaustiveness-checked;switchover a sealed type is.
Test yourself
Answer these before moving on — recall is what makes it stick.