Skip to content

How to Use instanceof Pattern Matching in Java 16 and 21

Replace instanceof-plus-cast with pattern matching, route support tickets in plain Java 21, and see how scope, null and record patterns behave.

how-to-tutorials modern-java core-java
Gopi Gorantala
Reading Progress

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. var is allowed inside the parentheses and means "whatever type that component has".
  • body.isBlank() after the && works for the same reason c.text() did.
  • batch instanceof List<?> list uses the unbounded wildcard. You can't write List<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): t is in scope on the right of && and in the then branch.
  • a instanceof T t || use(t): not in scope. If the left side were false the right side runs, and t was never assigned.
  • if (!(a instanceof T t)) { exit; } use(t): in scope after the if, but only because the then branch can't complete normally.
  • if (!(a instanceof T t)) { log(); } use(t): not in scope. The then branch 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 switch when you have three or more branches on type. My queue method has five instanceof branches; with sealed event types a switch would 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 instanceof is the right tool. If every event type had a queue() method, you'd need no router. That's the strategy pattern: How to implement the strategy pattern in Java. instanceof fits 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 instanceof plus 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 instanceof with 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, null is never an instance, and record components can still be null.

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.

References

how-to-tutorialsmodern-javacore-java

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments