Optional & Modern Java

Enhanced instanceof & Record Patterns

15 min Lesson 9 of 13

Enhanced instanceof & Record Patterns

Before Java 16, testing whether an object is of a specific type and then using it as that type required two separate steps: an instanceof check followed by an explicit cast. Java 16 introduced pattern matching for instanceof, and Java 21 extended the same idea to record patterns — allowing you to deconstruct a record's components directly inside a pattern. Together these features eliminate a class of boilerplate that has existed in Java since its first release.

The Old Way: Test, Then Cast

Every Java developer has written code like this:

Object obj = getShape(); if (obj instanceof Circle) { Circle c = (Circle) obj; // redundant cast — we already know it is a Circle System.out.println("Radius: " + c.radius()); }

The cast on the second line is purely ceremonial. The compiler already knows the type because the instanceof check passed. The cast exists only because the language required it.

Pattern Matching for instanceof

With pattern matching you combine the test and the binding into a single expression:

Object obj = getShape(); if (obj instanceof Circle c) { // c is already typed as Circle — no cast needed System.out.println("Radius: " + c.radius()); }

The pattern variable c is in scope only where the pattern is guaranteed to have matched. The compiler enforces this: you cannot accidentally use c in a branch where the test failed.

Scope and definite assignment: The pattern variable is only in scope in the true branch of the if, not in the else branch and never outside the statement. The compiler rejects any attempt to use it where the match is not guaranteed.

Using the Pattern Variable in Conditions

Pattern variables can appear in the same boolean expression, enabling compact guards:

// matches only non-null Strings with length > 5 if (obj instanceof String s && s.length() > 5) { System.out.println("Long string: " + s); }

Because Java short-circuits &&, the right-hand side is only reached when the pattern matched, so s is guaranteed to be a String there. The same logic means || would not be safe — and the compiler rejects it.

Pattern Matching in Switch Expressions (Java 21)

Java 21 generalised patterns to switch. You can match on type, bind a variable, and add a when guard in a single arm:

sealed interface Shape permits Circle, Rectangle, Triangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} record Triangle(double base, double height) implements Shape {} double area(Shape shape) { return switch (shape) { case Circle c -> Math.PI * c.radius() * c.radius(); case Rectangle r -> r.width() * r.height(); case Triangle t -> 0.5 * t.base() * t.height(); }; }

Because Shape is sealed, the compiler knows the three permitted subtypes are exhaustive and does not require a default arm. Adding a fourth subtype later turns the missing arm into a compile error — not a silent runtime failure.

When Guards

A when clause refines a pattern arm with an arbitrary boolean expression:

String describe(Object obj) { return switch (obj) { case Integer i when i < 0 -> "negative integer: " + i; case Integer i when i == 0 -> "zero"; case Integer i -> "positive integer: " + i; case String s when s.isEmpty() -> "empty string"; case String s -> "string: " + s; default -> "other: " + obj; }; }

Arms are evaluated top-to-bottom. The first arm whose type pattern matches and whose when guard is true wins. This makes the ordering meaningful — unlike traditional switch on primitives, falling through is not an issue.

Best practice with when guards: Place more specific guards before less specific ones for the same type. The compiler does not warn about unreachable arms for guards (only for type patterns), so ordering is your responsibility.

Record Patterns: Deconstruction in Place

Java 21 introduced record patterns, which let you match a record type and bind its components simultaneously. Instead of calling accessors after the match, you name the components directly in the pattern:

record Point(int x, int y) {} record Line(Point start, Point end) {} Object obj = new Line(new Point(1, 2), new Point(3, 4)); // Without record patterns if (obj instanceof Line line) { int x1 = line.start().x(); int y1 = line.start().y(); System.out.println("Starts at " + x1 + ", " + y1); } // With record patterns — components bound directly if (obj instanceof Line(Point start, Point end)) { System.out.println("Starts at " + start.x() + ", " + start.y()); System.out.println("Ends at " + end.x() + ", " + end.y()); }

The record pattern Line(Point start, Point end) both tests that obj is a Line and deconstructs it into its two components in a single operation.

Nested Record Patterns

Record patterns compose. You can nest them to reach deeply into a data structure without intermediate variables:

// Nested deconstruction: match a Line whose start is at the origin if (obj instanceof Line(Point(int x, int y), Point end) && x == 0 && y == 0) { System.out.println("Line starts at the origin, ends at " + end); }

The inner pattern Point(int x, int y) deconstructs the start component. The outer pattern deconstructs the Line. Both bindings are in scope in the body.

Null safety: A record pattern never matches null. If obj is null, the entire pattern fails immediately — no NullPointerException is thrown. This is consistent with all pattern matching in Java.

Record Patterns in Switch

Record patterns shine most in switch where multiple shapes need to be deconstructed:

sealed interface Expr permits Num, Add, Mul {} record Num(int value) implements Expr {} record Add(Expr left, Expr right) implements Expr {} record Mul(Expr left, Expr right) implements Expr {} int eval(Expr expr) { return switch (expr) { case Num(int v) -> v; case Add(Expr l, Expr r) -> eval(l) + eval(r); case Mul(Expr l, Expr r) -> eval(l) * eval(r); }; } // eval(new Add(new Num(2), new Mul(new Num(3), new Num(4)))) == 14

This is a complete, recursive expression evaluator in seven lines. The record patterns extract l and r from the Add and Mul nodes on the same line that identifies the node type — no intermediate variables, no accessors.

Trade-offs and When to Use These Features

  • Use pattern matching for instanceof whenever you would previously have written a test followed by a cast. It is strictly better: less noise, no hidden ClassCastException risk from a mistyped variable name.
  • Prefer sealed hierarchies + switch patterns over long if/else instanceof chains. The exhaustiveness check makes the code more robust and easier to maintain.
  • Use record patterns when the structure of the data is the point. They make algorithmic code that walks recursive data (expression trees, ASTs, JSON models) dramatically cleaner.
  • Do not over-nest. Deeply nested record patterns (A(B(C(D d)))) can be hard to read. If the structure is more than two levels deep, extracting an intermediate variable is often clearer.

Summary

Pattern matching for instanceof (Java 16) replaces the test-then-cast idiom with a single binding expression. Switch patterns (Java 21) extend this to multi-arm dispatching with when guards and sealed-type exhaustiveness checks. Record patterns (Java 21) add deconstruction — binding a record's components directly in the pattern. These three features together make type-driven code in Java concise, safe, and compiler-verified, bringing the language in line with the pattern-matching capabilities found in Haskell, Scala, and Kotlin.