> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use instanceof Pattern Matching in Java 16 and 21
- URL: https://www.ggorantala.dev/how-to-use-instanceof-pattern-matching-java/
- Published: 2026-08-27T05:16:00.000Z
- Updated: 2026-10-31T06:32:39.000Z
- Description: Replace instanceof-plus-cast with pattern matching, route support tickets in plain Java 21, and see how scope, null and record patterns behave.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, modern-java, core-java

## Delete the cast: the router before and after

Here's the line this whole article exists to remove:

```java
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:

```java
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):

```log
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](https://ggorantala.dev/how-to-use-switch-expressions-pattern-matching-java/?ref=ggorantala.dev). 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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev), 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.

```bash
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:

```log
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`

```kotlin
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`

```kotlin
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`

```java
package com.example.tickets;

public record Email(String from, String subject, String body) {}

```

`src/main/java/com/example/tickets/Chat.java`

```java
package com.example.tickets;

public record Chat(String user, String text) {}

```

`src/main/java/com/example/tickets/Call.java`

```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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) 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`)

```java
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:

```java
} 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.

```java
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)

```java
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.

```bash
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`

```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](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev).

### Step 7: Write the demo

`src/main/java/com/example/tickets/Demo.java`

```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

```bash
javac -Xlint:all -d out $(find src -name '*.java')
java -cp out com.example.tickets.Demo

```

```log
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`

```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);
    }
}

```

```bash
javac -d out $(find src -name '*.java')
java -cp out com.example.tickets.Checks

```

```log
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:

```log
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, ""))`:

```log
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 `||`**

```java
if (o instanceof String s || s.length() > 3) { ... }

```

```log
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 `if`s.

**Impossible pattern**

```java
static void f(Integer i) { if (i instanceof String s) { ... } }

```

```log
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**

```java
if (o instanceof List<String> l) { ... }

```

```log
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**

```java
String s = "x";
if (o instanceof String s) { ... }

```

```log
error: variable s is already defined in method f(Object)

```

Fix: rename one of them.

**Using the variable after an `if` that falls through**

```java
if (!(o instanceof String s)) { System.out.println("no"); }
System.out.println(s.length());

```

```log
error: cannot find symbol
  symbol:   variable s

```

Fix: make the `then` branch `return`, `throw` or `continue`.

**Compiling for an older release**

```bash
javac --release 17 -d out $(find src/main -name '*.java')

```

```log
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](https://ggorantala.dev/how-to-use-sealed-classes-interfaces-java/?ref=ggorantala.dev).
- **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](https://ggorantala.dev/how-to-implement-strategy-pattern-java/?ref=ggorantala.dev). `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

- [JEP 394: Pattern Matching for instanceof](https://openjdk.org/jeps/394?ref=ggorantala.dev)
- [JEP 440: Record Patterns](https://openjdk.org/jeps/440?ref=ggorantala.dev)
- [JLS 21, section 15.20.2: The instanceof operator](https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html?ref=ggorantala.dev#jls-15.20.2)
- [JLS 21, section 6.3.1: Scope for pattern variables](https://docs.oracle.com/javase/specs/jls/se21/html/jls-6.html?ref=ggorantala.dev#jls-6.3.1)
- [dev.java: Pattern Matching](https://dev.java/learn/pattern-matching/?ref=ggorantala.dev), seen in the search results on 2026-10-28