On This Page
Delete the cast: the router before and after
Here's the line this whole article exists to remove:
Email e = (Email) event;
Every instanceof check in old Java code is followed by one of those. You test the type, then you say the type again, then you cast, then you finally use the thing. With instanceof pattern matching in Java (final since Java 16) the test and the variable are one expression, and the compiler does the cast for you:
if (event instanceof Email e) {
return e.subject().toLowerCase().contains("refund") ? "billing" : "inbox";
}
You'll build a small support-ticket router. Events arrive as plain Object (an email, a chat message, a phone call, and sometimes garbage), and the router decides which queue each one lands in. You'll write it the old way first, then rewrite it, then prove the two behave identically. Along the way you'll hit the three things that trip people up: where the pattern variable is in scope, why || breaks it, and why List<String> isn't allowed on the right-hand side. Java 21 adds record patterns on top, so you'll use those too.
This is for anyone who can read Java but hasn't switched their instanceof habits yet. When you finish, this is what the demo prints (run on my machine, copied as is):
Email -> billing | old: billing | ok
Email -> inbox | old: inbox | raj@example.com sent "Password reset" with no body
Chat -> live-chat | old: live-chat | ok
Chat -> escalations | old: escalations | ok
Call -> escalations | old: escalations | ok
Call -> callbacks | old: callbacks | +44 20 7946 0001 hung up
String -> unrouted | old: unrouted | ok
Integer -> unrouted | old: unrouted | ok
sender(chat) = n/a
totalLength = 5
queue(null) = unrouted
The switch flavour of pattern matching is a separate topic; it's in How to use switch expressions and pattern matching. This one is only about instanceof.
Check your setup
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| Gradle (optional path) | 8.14.3, Kotlin DSL | gradle -v |
| IntelliJ IDEA / Eclipse | not run here, see the notes in the next section | Help → About |
You need JDK 16 or newer for the basic pattern and JDK 21 for the record-pattern part. If java -version prints 17 or lower, install a newer JDK first (SDKMAN works well: sdk install java 21.0.x-tem, and pick the exact id from sdk list java). No database, no container, no framework. I wanted the language feature in isolation.
I couldn't reach Maven Central from my build machine this time, so there's no JUnit in this project. The tests are a small Checks class with its own main that counts passes and fails. The same assertions drop straight into JUnit 5 later (see How to write unit tests with JUnit 5, which is on my list).
Lay out the ticket-desk folders and compile once
I used the command line as the primary path. The whole build took me about 25 minutes, and most of it was deliberately breaking things for the troubleshooting section.
mkdir -p ticket-desk/src/main/java/com/example/tickets
mkdir -p ticket-desk/src/test/java/com/example/tickets
cd ticket-desk
The tree you're heading for:
ticket-desk/
├── build.gradle.kts (only if you use Gradle)
├── settings.gradle.kts (only if you use Gradle)
└── src/
├── main/java/com/example/tickets/
│ ├── Agent.java
│ ├── Call.java
│ ├── Chat.java
│ ├── Demo.java
│ ├── Email.java
│ └── Router.java
└── test/java/com/example/tickets/
└── Checks.java
In IntelliJ IDEA
Create a new Java project, then open File → Project Structure → Project and set the SDK to your JDK 21 and the language level to 21. Copy the source files in below. I didn't run IntelliJ for this article; those menu names come from JetBrains' documentation.
In Eclipse
Create a Java project, then Window → Preferences → Java → Compiler and set the compliance level to 21 (you need an Eclipse release that supports Java 21; check its release notes). Same caveat: documentation, not executed.
With Gradle instead of javac
Skip this if you're using plain javac. Otherwise create these two files in the project root:
build.gradle.kts
plugins {
application
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
mainClass = "com.example.tickets.Demo"
}
// Checks has its own main(), so run it with a small task instead of JUnit.
tasks.register<JavaExec>("checks") {
classpath = sourceSets["test"].runtimeClasspath
mainClass = "com.example.tickets.Checks"
}
settings.gradle.kts
rootProject.name = "ticket-desk"
Then gradle run starts the demo and gradle checks runs the checks. I ran both with gradle --offline; the first run took 27.8 seconds because of the daemon start. Docker and Podman don't come into this article at all.
Use instanceof pattern matching on the router, one rule at a time
Step 1: Define the three event types
Goal: three small records for things that arrive in the queue.
src/main/java/com/example/tickets/Email.java
package com.example.tickets;
public record Email(String from, String subject, String body) {}
src/main/java/com/example/tickets/Chat.java
package com.example.tickets;
public record Chat(String user, String text) {}
src/main/java/com/example/tickets/Call.java
package com.example.tickets;
public record Call(String caller, int minutes) {}
Records matter later, for the deconstruction patterns. If records are new to you, How to use Java records covers them.
Checkpoint: nothing to run yet. javac -d out src/main/java/com/example/tickets/*.java should print nothing.
Step 2: Write the router the old way
Goal: a baseline whose behaviour you can compare against later.
src/main/java/com/example/tickets/Router.java (first version, only queueOld)
package com.example.tickets;
public final class Router {
private Router() {}
public static String queueOld(Object event) {
if (event instanceof Email) {
Email e = (Email) event;
return e.subject().toLowerCase().contains("refund") ? "billing" : "inbox";
} else if (event instanceof Chat) {
Chat c = (Chat) event;
return c.text().length() > 200 ? "escalations" : "live-chat";
} else if (event instanceof Call) {
Call c = (Call) event;
return c.minutes() >= 10 ? "escalations" : "callbacks";
}
return "unrouted";
}
}
Why it's worth writing out: three casts, each a place where someone can paste the wrong type name and get a ClassCastException at runtime. The compiler can't tell the test and the cast belong together.
Step 3: Rewrite it with a pattern
Goal: the same rules, no casts.
Take the new queue method from the full Router.java in Step 5. The first branch becomes event instanceof Email e. The chat branch is the interesting one:
} else if (event instanceof Chat c && c.text().length() > 200) {
return "escalations";
} else if (event instanceof Chat) {
return "live-chat";
}
The && works because the pattern variable c is definitely assigned whenever the left side is true. You can use it on the right of the && in the same condition. The second Chat branch has no variable because it doesn't need one.
I put the long-chat check first on purpose. If you swap the two branches, or delete the && part, every chat escalates. I did exactly that as a mutation test and three checks failed (details in the testing section).
Step 4: Add the negated form
Goal: bail out early and keep the variable afterwards.
public static String sender(Object event) {
if (!(event instanceof Email e)) {
return "n/a";
}
return e.from();
}
This is the form I use most in real code. The if always exits, so the compiler knows e is assigned for everything after it. No nesting, no else.
Step 5: Add record patterns and a generic check
Goal: take records apart inside the instanceof, and handle a list of unknown contents.
src/main/java/com/example/tickets/Router.java (final version)
package com.example.tickets;
import java.util.List;
/** Decides which queue an incoming ticket event lands in. Events arrive as plain Objects. */
public final class Router {
private Router() {}
/** The old way: test, then cast, then use. */
public static String queueOld(Object event) {
if (event instanceof Email) {
Email e = (Email) event;
return e.subject().toLowerCase().contains("refund") ? "billing" : "inbox";
} else if (event instanceof Chat) {
Chat c = (Chat) event;
return c.text().length() > 200 ? "escalations" : "live-chat";
} else if (event instanceof Call) {
Call c = (Call) event;
return c.minutes() >= 10 ? "escalations" : "callbacks";
}
return "unrouted";
}
/** The same rules with pattern matching: the cast disappears. */
public static String queue(Object event) {
if (event instanceof Email e) {
return e.subject().toLowerCase().contains("refund") ? "billing" : "inbox";
} else if (event instanceof Chat c && c.text().length() > 200) {
return "escalations";
} else if (event instanceof Chat) {
return "live-chat";
} else if (event instanceof Call c) {
return c.minutes() >= 10 ? "escalations" : "callbacks";
}
return "unrouted";
}
/** Negated test: the variable is in scope AFTER the if, because the if always exits. */
public static String sender(Object event) {
if (!(event instanceof Email e)) {
return "n/a";
}
return e.from();
}
/** Record patterns (Java 21): take the record apart inside the instanceof. */
public static String summary(Object event) {
if (event instanceof Email(String from, String subject, var body) && body.isBlank()) {
return from + " sent \"" + subject + "\" with no body";
}
if (event instanceof Call(var caller, var minutes) && minutes == 0) {
return caller + " hung up";
}
return "ok";
}
/** A generic type can only be tested with an unbounded wildcard. */
public static int totalLength(Object batch) {
if (batch instanceof List<?> list) {
int sum = 0;
for (Object o : list) {
if (o instanceof String s) {
sum += s.length();
}
}
return sum;
}
return -1;
}
}
Three things to notice:
Email(String from, String subject, var body)is a record pattern (final in Java 21). It checks the type, then pulls out the components and binds them to new variables.varis allowed inside the parentheses and means "whatever type that component has".body.isBlank()after the&&works for the same reasonc.text()did.batch instanceof List<?> listuses the unbounded wildcard. You can't writeList<String>there; the troubleshooting section shows the exact compiler message.
Checkpoint: compile with every lint warning on.
mkdir -p out
javac -Xlint:all -d out $(find src -name '*.java')
On my machine that printed nothing, which is the output you want.
Step 6: Use a pattern inside equals()
Goal: the classic place pattern matching shortens real code.
src/main/java/com/example/tickets/Agent.java
package com.example.tickets;
import java.util.Objects;
/** An equals() written with a pattern. */
public final class Agent {
private final String id;
public Agent(String id) {
this.id = Objects.requireNonNull(id);
}
@Override
public boolean equals(Object o) {
return o instanceof Agent a && a.id.equals(id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
o instanceof Agent a && a.id.equals(id) replaces the usual four lines: null check, class check, cast, compare. A null o makes instanceof false, so the null check is free. If you want the full contract (symmetry, hashCode, subclasses), read How to implement equals() and hashCode() properly.
Step 7: Write the demo
src/main/java/com/example/tickets/Demo.java
package com.example.tickets;
import java.util.List;
public class Demo {
public static void main(String[] args) {
List<Object> feed = List.of(
new Email("ana@example.com", "Refund for order 118", "Charged twice."),
new Email("raj@example.com", "Password reset", ""),
new Chat("mei", "Is the portal down?"),
new Chat("tom", "x".repeat(250)),
new Call("+44 20 7946 0000", 12),
new Call("+44 20 7946 0001", 0),
"stray string",
42);
for (Object event : feed) {
System.out.printf("%-12s -> %-12s | old: %-12s | %s%n",
event.getClass().getSimpleName(), Router.queue(event),
Router.queueOld(event), Router.summary(event));
}
System.out.println("sender(chat) = " + Router.sender(new Chat("a", "b")));
System.out.println("totalLength = " + Router.totalLength(List.of("ab", "cde", 7)));
System.out.println("queue(null) = " + Router.queue(null));
}
}
See it route eight events
javac -Xlint:all -d out $(find src -name '*.java')
java -cp out com.example.tickets.Demo
Email -> billing | old: billing | ok
Email -> inbox | old: inbox | raj@example.com sent "Password reset" with no body
Chat -> live-chat | old: live-chat | ok
Chat -> escalations | old: escalations | ok
Call -> escalations | old: escalations | ok
Call -> callbacks | old: callbacks | +44 20 7946 0001 hung up
String -> unrouted | old: unrouted | ok
Integer -> unrouted | old: unrouted | ok
sender(chat) = n/a
totalLength = 5
queue(null) = unrouted
Read the second column against the third: new and old agree on every row, including String and Integer, and including null. That last one is the quiet win. null instanceof anything is false, so queue(null) falls through to unrouted with no special case. The old code had the same property, but people forget it and add if (event == null) anyway.
One row deserves a second look. raj@example.com sent "Password reset" with no body printed because the record pattern matched an Email whose body is "". That's summary, not queue, doing its job.
Pin it down with 29 checks
src/test/java/com/example/tickets/Checks.java
package com.example.tickets;
import java.util.List;
public class Checks {
static int passed, failed;
static void check(String name, Object expected, Object actual) {
if (expected.equals(actual)) { passed++; }
else { failed++; System.out.println("FAIL " + name + ": expected " + expected + " got " + actual); }
}
public static void main(String[] args) {
check("refund email", "billing", Router.queue(new Email("a", "REFUND please", "x")));
check("plain email", "inbox", Router.queue(new Email("a", "Hello", "x")));
check("short chat", "live-chat", Router.queue(new Chat("u", "hi")));
check("200 chars chat", "live-chat", Router.queue(new Chat("u", "x".repeat(200))));
check("201 chars chat", "escalations", Router.queue(new Chat("u", "x".repeat(201))));
check("9 min call", "callbacks", Router.queue(new Call("1", 9)));
check("10 min call", "escalations", Router.queue(new Call("1", 10)));
check("string", "unrouted", Router.queue("hello"));
check("null", "unrouted", Router.queue(null));
for (Object o : List.of(new Email("a", "refund", ""), new Chat("u", "hi"), new Chat("u", "x".repeat(300)),
new Call("1", 3), new Call("1", 30), 5, "s")) {
check("old==new " + o.getClass().getSimpleName(), Router.queueOld(o), Router.queue(o));
}
check("sender email", "a@b.c", Router.sender(new Email("a@b.c", "s", "b")));
check("sender other", "n/a", Router.sender("x"));
check("sender null", "n/a", Router.sender(null));
check("blank body", "a sent \"s\" with no body", Router.summary(new Email("a", "s", " ")));
check("body present", "ok", Router.summary(new Email("a", "s", "text")));
check("hung up", "z hung up", Router.summary(new Call("z", 0)));
check("call ok", "ok", Router.summary(new Call("z", 4)));
check("list total", 5, Router.totalLength(List.of("ab", "cde", 7)));
check("not a list", -1, Router.totalLength("abc"));
check("equals same", true, new Agent("a1").equals(new Agent("a1")));
check("equals diff", false, new Agent("a1").equals(new Agent("a2")));
check("equals null", false, new Agent("a1").equals(null));
check("equals other type", false, new Agent("a1").equals("a1"));
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
javac -d out $(find src -name '*.java')
java -cp out com.example.tickets.Checks
29 passed, 0 failed
The loop in the middle is the one I care about: seven inputs, and for each one queueOld and queue must return the same answer. That's the proof the rewrite changed no behaviour. The boundary checks (200 vs 201 characters, 9 vs 10 minutes) catch an off-by-one if you ever flip > to >=.
Mutation check. I removed && c.text().length() > 200 from the first Chat branch and recompiled:
FAIL short chat: expected live-chat got escalations
FAIL 200 chars chat: expected live-chat got escalations
FAIL old==new Chat: expected live-chat got escalations
26 passed, 3 failed
Three failures, and each one names the exact case. Good.
What the compiler does with the pattern variable
When you write x instanceof Email e, three things happen. The type test runs. If it's true, e is assigned the value with the narrowed static type. And e exists only in the places where the compiler can prove the test succeeded. The language spec calls this flow scoping. It's not block scoping: e can live past the closing brace of an if (Step 4), and it can be missing inside the very same if line (the || case below).
The rules fit in four lines:
a instanceof T t && use(t):tis in scope on the right of&&and in thethenbranch.a instanceof T t || use(t): not in scope. If the left side were false the right side runs, andtwas never assigned.if (!(a instanceof T t)) { exit; } use(t): in scope after theif, but only because thethenbranch can't complete normally.if (!(a instanceof T t)) { log(); } use(t): not in scope. Thethenbranch falls through.
The generated bytecode is what you'd have written by hand: an instanceof instruction, a checkcast, and a store into a local. What changed is that you can't separate the test from the cast any more.
I checked with javap -c on sender: instanceof, then checkcast, then astore_1. Nothing exotic. The JLS sections in Sources have the exact scoping rules.
One surprise from my own run, and worth remembering: record-pattern components match null. I built new Email(null, "s", "b") and called Router.summary(new Email("a", null, "")):
null
a sent "null" with no body
String subject inside a record pattern is a total pattern for that component, so it matches null too. If a null component would hurt, check it explicitly. And yes, that output means "null" ended up in the message.
Compiler errors I forced, and the fixes
I wrote each of these on purpose, compiled with javac 21, and pasted the first line of what came back.
Using the variable after ||
if (o instanceof String s || s.length() > 3) { ... }
error: cannot find symbol
if (o instanceof String s || s.length() > 3) { return "a"; }
^
symbol: variable s
Cause: with ||, the right side can run when the pattern failed. Fix: use &&, or split into two ifs.
Impossible pattern
static void f(Integer i) { if (i instanceof String s) { ... } }
error: incompatible types: Integer cannot be converted to String
Cause: Integer and String are unrelated, so the test can never succeed. The compiler stops you. Fix: widen the variable's static type to Object, or pick the right type.
A generic type in the pattern
if (o instanceof List<String> l) { ... }
error: Object cannot be safely cast to List<String>
Cause: at runtime a List has no idea what's in it. Fix: List<?> l, then test the elements one at a time like totalLength does.
A name that's already taken
String s = "x";
if (o instanceof String s) { ... }
error: variable s is already defined in method f(Object)
Fix: rename one of them.
Using the variable after an if that falls through
if (!(o instanceof String s)) { System.out.println("no"); }
System.out.println(s.length());
error: cannot find symbol
symbol: variable s
Fix: make the then branch return, throw or continue.
Compiling for an older release
javac --release 17 -d out $(find src/main -name '*.java')
Router.java:49: error: deconstruction patterns are not supported in -source 17
(use -source 21 or higher to enable deconstruction patterns)
With --release 11 and a plain instanceof String s, I got pattern matching in instanceof is not supported in -source 11, with a hint to use 16 or higher. That tells you the cut-off: simple pattern from 16, record patterns from 21.
One that compiled when I expected it not to: String x; if (x instanceof String s), an always-true test, built without complaint on JDK 21. I only ran 21, so I won't make claims about older releases.
Taking it into a real codebase
- Don't rewrite everything. Use the pattern in new code and when you're touching a method anyway. The old form isn't wrong.
- Prefer
switchwhen you have three or more branches on type. Myqueuemethod has fiveinstanceofbranches; with sealed event types aswitchwould be flatter and the compiler would tell you when you miss a case. See How to use sealed classes and interfaces. - Better still, ask whether
instanceofis the right tool. If every event type had aqueue()method, you'd need no router. That's the strategy pattern: How to implement the strategy pattern in Java.instanceoffits best when you don't own the types. - Version notes. Preview in 14 and 15, final in 16 (JEP 394). Record patterns arrived as preview in 19, 20, final in 21 (JEP 440). If your build pins
--release 17, the plain pattern is fine and record patterns aren't. - Pattern variables aren't
final. You can reassign them (the old preview rules said otherwise). I'd still treat them as constants.
What you did
- Wrote the old
instanceofplus cast router, rewrote it with patterns, and proved the two agree on seven inputs. - Used
&&to add a condition on the pattern variable and the negated!(... instanceof ...)form for early exit. - Took a record apart inside an
instanceofwith a record pattern (Java 21). - Tested a generic container with
List<?>and checked the elements one by one. - Wrote a four-line
equals()that is also null-safe. - Remember:
||never sees the variable,nullis never an instance, and record components can still benull.
instanceof questions people ask
What Java version do I need for instanceof pattern matching? Java 16 or newer for x instanceof Type t. It was a preview feature in 14 and 15. Record patterns, like x instanceof Email(var from, var subject, var body), need Java 21.
Can I use instanceof pattern matching with generics? Only if the cast is safe. o instanceof List<?> l compiles; o instanceof List<String> l doesn't, because the element type isn't known at runtime.
Does a pattern variable work after the if statement? Yes, when the if tests the negation and the then branch always exits (return, throw, continue or break). If the branch can fall through, the compiler rejects any use after the if.
Is instanceof pattern matching faster than casting? I didn't benchmark it, so I won't claim a number. The compiler emits the same type test and cast you'd write by hand, so I wouldn't pick it for speed. Pick it because the variable can't be wrong.
Does instanceof return true for null? No, never. That's why a pattern check doubles as a null check.