Skip to content

Pattern Matching in Java: instanceof, switch, and Record Patterns

A light maple marble run on a cream background. A groove holding a green glass pyramid and a green glass disc leads from the left into a block with four exits – round, square, triangular, and plain. A glowing amber sphere rolls out of the round exit, a glass cube out of the square one

Pattern matching checks whether a value has a certain shape and, in the same step, takes it apart into the pieces you need. In Java, this means: instead of checking the type with instanceof and casting afterward, you write the type and a variable name directly into the check. And instead of calling a record’s accessor methods, you take the record apart into its components.

The following method shows almost everything this article is about:

record Point(int x, int y) {}

String describe(Object obj) {
  return switch (obj) {
    case String s when s.length() > 5 -> "long string: " + s;
    case String s                     -> "string: " + s;
    case Integer i                    -> "integer, squared: " + i * i;
    case Point(int x, int y)          -> "point at " + x + "/" + y;
    case null                         -> "null";
    default                           -> "something else";
  };
}

For the arguments "Hello, world", "Hello", 12, new Point(3, 4), 3.14, and null, it returns:

long string: Hello, world
string: Hello
integer, squared: 144
point at 3/4
something else
null

Each case checks obj against a pattern. The pattern String s matches any string and binds it to the variable s. The condition when s.length() > 5 restricts the case to long strings. The pattern Point(int x, int y) matches any Point and binds its two components to x and y. And case null handles the case that would otherwise end in a NullPointerException.

Java has gained pattern matching step by step: patterns in instanceof have been available since Java 16, patterns in switch and record patterns since Java 21, and the underscore for unnamed patterns since Java 22. Primitive types in patterns are in their fifth preview in Java 27.

In this article, you will learn:

  • what a pattern is and which kinds of patterns exist,
  • what the instanceof operator checks and why it works only to a limited extent with generics,
  • how Pattern Matching for instanceof saves you the cast after an instanceof,
  • where a pattern variable is in scope,
  • how you branch on types with Pattern Matching for switch, narrow cases with when, and handle null,
  • why the compiler cares about the order of the cases and when a switch is exhaustive,
  • how you take records apart with record patterns, also nested ones,
  • how you combine sealed interfaces, records, and switch,
  • which mistakes are typical in pattern matching.

The Examples in This Article

Almost all examples are compact source files: files with a void main() method and no enclosing class, which you can run directly with java File.java. Compact source files and the IO class, which the examples use to print their results to the console, have existed since Java 25. The examples therefore require Java 25. The features themselves are older; each section says when they were introduced.

You can find the complete code in the GitHub repository java-pattern-matching, each listing as a file of its own. The runnable examples are in src/main/java/ and print the outputs shown here. The listings with a compiler error are in does-not-compile/, the preview example in preview/.

What Is Pattern Matching?

A pattern consists of a test and of variables. The pattern String s, for example, tests whether a value is a String. If the value matches, the pattern binds it to the pattern variable s – with the type String, without a cast.

Patterns appear in two places: to the right of instanceof and after case in a switch. Java has four kinds of patterns:

PatternExampleChecksSince
Type patternString sthe typeJava 16 (instanceof),
Java 21 (switch)
Record patternPoint(int x, int y)the type and each componentJava 21
Unnamed pattern_nothing – matches any component of a recordJava 22
Primitive type patternbyte bwhether the value fits into the typepreview since Java 23

The first three are final; the fourth is a preview feature. I explain all four over the course of this article. Patterns came to the instanceof operator first – so we start with that operator, still without patterns.

The instanceof Operator

x instanceof T returns true if x is not null and can be cast to the type T without a ClassCastException – that is, if the object is an instance of the class T or one of its subclasses, or implements the interface T.

The following listing first checks the string "Hello" against four different types and then null against Object:

Object obj = "Hello";
IO.println(obj instanceof String);
IO.println(obj instanceof CharSequence);
IO.println(obj instanceof Comparable);
IO.println(obj instanceof Integer);
IO.println();

Object nothing = null;
IO.println(nothing instanceof Object);
true
true
true
false

false

String implements the interfaces CharSequence and Comparable, so the first three checks return true. A String is not an Integer; that gives the first false.

The second false stands for null: null is not an instance of any type, not even of Object. So you don’t need a null check before the test, such as obj != null && obj instanceof String: if obj is null, obj instanceof String returns false anyway.

If a test can never be true, the compiler rejects it. A Number object, for example, can never be a String:

void check(Number number) {
  if (number instanceof String) {
    IO.println("never");
  }
}
error: incompatible types: Number cannot be converted to String
  if (number instanceof String) {
      ^

The same check takes sealed class hierarchies into account; you can find examples in the section instanceof Tests with Sealed Classes of the article on sealed classes.

instanceof and Generics

At runtime, an object no longer carries its type arguments (this is called type erasure): an ArrayList<String> is then just an ArrayList. That is why the compiler does not allow a test for List<String> on an Object:

void check(Object obj) {
  if (obj instanceof List<String> list) {
    IO.println(list.size());
  }
}
error: Object cannot be safely cast to List<String>
  if (obj instanceof List<String> list) {
      ^

The test is allowed when the compiler can derive the type argument from the type of the variable. A List<String> that is an ArrayList is always an ArrayList<String>:

List<String> names = new ArrayList<>(List.of("Anna", "Ben"));
if (names instanceof ArrayList<String> arrayList) {
  arrayList.ensureCapacity(100);
  IO.println("ArrayList with " + arrayList.size() + " elements");
}
ArrayList with 2 elements

This has worked since Java 16; up to Java 15, the compiler reported “illegal generic type for instanceof” here. If you don’t know the type argument, write the wildcard type: obj instanceof List<?> list.

Before Java 16: instanceof and Cast

Up to Java 15, almost every instanceof test was followed by a cast:

void print(Object obj) {
  if (obj instanceof String) {
    String s = (String) obj;
    IO.println("string of length " + s.length());
  } else if (obj instanceof Integer) {
    Integer i = (Integer) obj;
    IO.println("integer, squared: " + i * i);
  }
}

For "Hello" and 12, the method prints:

string of length 5
integer, squared: 144

Each branch names its type three times: in the test, in the variable declaration after the test, and in the cast. If the test checks for one type and the cast names another, the code still compiles – only a ClassCastException at runtime reports the mistake.

The following method, for example, checks whether obj is a Number, but casts to Integer:

void print(Object obj) {
  if (obj instanceof Number) {
    Integer i = (Integer) obj;
    IO.println("integer, squared: " + i * i);
  }
}

print(12) and print(12L) print:

integer, squared: 144
Exception in thread "main" java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.Integer (java.lang.Long and java.lang.Integer are in module java.base of loader 'bootstrap')
    at InstanceofWrongCast.print(InstanceofWrongCast.java:3)
    at InstanceofWrongCast.main(InstanceofWrongCast.java:10)

The compiler allows the cast because Integer is a subclass of Number. Whether obj is an Integer at runtime or another subtype of Number such as Long, the compiler cannot check.

Pattern Matching for instanceof

Since Java 16, a type pattern can also stand to the right of instanceof. The print() method with the branches for String and Integer then looks like this:

void print(Object obj) {
  if (obj instanceof String s) {
    IO.println("string of length " + s.length());
  } else if (obj instanceof Integer i) {
    IO.println("integer, squared: " + i * i);
  }
}
string of length 5
integer, squared: 144

If obj is a String, the first test returns true and binds obj to the pattern variable s of type String. If obj is not a String – or is null – the first test returns false, and s gets no value.

The else if branch then checks in the same way whether obj is an Integer and, if so, binds obj to the pattern variable i of type Integer. In both branches, the type now appears once instead of three times.

The Scope of a Pattern Variable

A pattern variable is in scope wherever the compiler knows for certain that the pattern has matched. JEP 394 calls this flow scoping: the scope follows the control flow, not the curly braces.

With if (obj instanceof String s), s is therefore in scope in the if block. If an && follows the test, s is also in scope on its right-hand side – and still in the if block, which then runs only if the right-hand side is true as well.

That is why the following method checks type and length in the same condition:

void print(Object obj) {
  if (obj instanceof String s && s.length() > 5) {
    IO.println("long string: " + s);
  } else if (obj instanceof Integer i) {
    IO.println("integer, squared: " + i * i);
  } else {
    IO.println("something else");
  }
}

For "Hello, world", "Hello", and 12, it prints:

long string: Hello, world
something else
integer, squared: 144

s.length() is only evaluated if obj is a string, because the && operator evaluates its right-hand side only if the left-hand side is true.

With ||, it is the other way around: the right-hand side is only evaluated if the left-hand side is false – that is, exactly when the pattern does not match and s has no value. The compiler therefore does not know s there:

void check(Object obj) {
  if (obj instanceof String s || s.isEmpty()) {
    IO.println("string or empty");
  }
}
error: cannot find symbol
  if (obj instanceof String s || s.isEmpty()) {
                                 ^
  symbol:   variable s

Flow scoping also works with negation. If the if block leaves the method as soon as the pattern does not match, the pattern variable is in scope after the if:

int length(Object obj) {
  if (!(obj instanceof String s)) {
    return -1;
  }
  return s.length();
}

For "Hello" and 12, the method returns:

5
-1

This way, you check preconditions at the start of a method and then work with the pattern variable without further indentation.

Pattern Matching in equals()

An equals() method first checks whether the other object has the right type and then compares the fields. With pattern matching and &&, this becomes a single expression:

final class Money {
  private final long cents;
  private final String currency;

  Money(long cents, String currency) {
    this.cents = cents;
    this.currency = currency;
  }

  @Override
  public boolean equals(Object o) {
    return o instanceof Money other
        && cents == other.cents
        && currency.equals(other.currency);
  }

  @Override
  public int hashCode() {
    return Long.hashCode(cents) * 31 + currency.hashCode();
  }
}

The three comparisons new Money(999, "EUR").equals(…) with new Money(999, "EUR"), new Money(999, "USD"), and the string "9.99 EUR" return:

true
false
false

instanceof covers the null case as well: equals(null) returns false because null instanceof Money is false.

By the way, for a class that only carries values, you don’t need to write equals() yourself at all: a record generates equals() and hashCode() automatically from its components.

Pattern Variables and Variables with the Same Name

A pattern variable hides a field with the same name (this is called shadowing) – just like any local variable. You then reach the field via this:

public class PatternShadowing {
  private String s = "field";

  void print(Object obj) {
    IO.println(s);
    if (obj instanceof String s) {
      IO.println(s);
      IO.println(this.s);
    }
  }

  void main() {
    print("pattern variable");
  }
}
field
pattern variable
field

A local variable with the same name, on the other hand, is not allowed:

void print(Object obj) {
  String s = "local variable";
  if (obj instanceof String s) {
    IO.println(s);
  }
}
error: variable s is already defined in method print(Object)
  if (obj instanceof String s) {
                            ^

Pattern Variables Are Not Final

You may assign a new value to a pattern variable:

void print(Object obj) {
  if (obj instanceof String s) {
    s = s.trim();
    IO.println("[" + s + "]");
  }
}

For the string " Hello " with two spaces at the beginning and two at the end, the method prints [Hello].

In the two previews in Java 14 and Java 15, pattern variables were still implicitly final; since Java 16, they behave like local variables. If you want the compiler to prevent an assignment, put final in front: obj instanceof final String s.

Pattern Matching for switch

When an if chain checks the same value against several types, it quickly gets long:

String describe(Object obj) {
  if (obj instanceof String s && s.length() > 5) {
    return "long string: " + s;
  } else if (obj instanceof String s) {
    return "string: " + s;
  } else if (obj instanceof Integer i) {
    return "integer, squared: " + i * i;
  } else {
    return "something else";
  }
}

Since Java 21, you can write the same logic as a switch with patterns:

String describe(Object obj) {
  return switch (obj) {
    case String s when s.length() > 5 -> "long string: " + s;
    case String s                     -> "string: " + s;
    case Integer i                    -> "integer, squared: " + i * i;
    default                           -> "something else";
  };
}

For "Hello, world", "Hello", 12, and 3.14, both methods return:

long string: Hello, world
string: Hello
integer, squared: 144
something else

In the switch, obj appears only once, and each case takes one line. The switch checks the cases from top to bottom and executes the first case whose pattern matches.

The arrow notation and switch as an expression with a return value have existed since Java 14; the article on switch expressions explains both. Pattern Matching for switch works in both notations – the arrow notation (case … ->) and the classic colon notation (case …:) – and in statements and expressions alike.

What is also new is the type of the selector – the value in the parentheses after switch. Up to Java 20, it could only be char, byte, short, int, their wrapper classes, String, or an enum. Since Java 21, any reference type is allowed.

Guards with when

A type pattern only checks the type. You add further conditions with when, as in case String s when s.length() > 5 above. The expression after when is called a guard. It may use the pattern variables of its case and must return true or false.

Why when and not && as with instanceof? With instanceof, the whole test obj instanceof String s is a boolean expression, and the && operator joins it with a second boolean expression. After case, in contrast, there is not a boolean expression but a pattern: String s on its own returns neither true nor false. A case therefore needs a keyword of its own for the additional condition.

A guard may also use local variables from outside the switch, but only if they are final or effectively final, that is, not changed after their initialization. The following method changes limit before the switch:

String describe(Object obj, int limit) {
  limit++;
  return switch (obj) {
    case String s when s.length() > limit -> "long string: " + s;
    default                               -> "something else";
  };
}
error: local variables referenced from a guard must be final or effectively final
    case String s when s.length() > limit -> "long string: " + s;
                                    ^

The same rule applies to lambda expressions.

case null

A switch throws a NullPointerException if the selector is null – that has always been the case and still is. So the describe() method from the section Pattern Matching for switch executes none of its cases for null, not even default:

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:220)

Since Java 21, you can handle null with a case of its own. case null stands alone or together with default as case null, default:

String describe(Object obj) {
  return switch (obj) {
    case String s  -> "string: " + s;
    case Integer i -> "integer, squared: " + i * i;
    case null      -> "null";
    default        -> "something else";
  };
}

String describeOrDefault(Object obj) {
  return switch (obj) {
    case String s      -> "string: " + s;
    case Integer i     -> "integer, squared: " + i * i;
    case null, default -> "null or something else";
  };
}

describe(null), describeOrDefault(null), and describeOrDefault(3.14) return:

null
null or something else
null or something else

case null works in every switch with a reference type as its selector, also without patterns. A classic switch over strings handles null this way, too:

String httpMethod(String method) {
  return switch (method) {
    case "GET", "HEAD" -> "read-only";
    case "POST", "PUT" -> "writes data";
    case null          -> "no method given";
    default            -> "unknown: " + method;
  };
}

httpMethod("GET") and httpMethod(null) return:

read-only
no method given

The Order of Cases: Dominance

Since the switch executes the first matching case, an earlier case can completely hide a later one. The compiler checks for this and reports a case that can never be reached as dominated.

This happens when a supertype comes before its subtype. Every Integer is a Number, so the second case is never executed:

String describe(Object obj) {
  return switch (obj) {
    case Number n  -> "number: " + n;
    case Integer i -> "integer: " + i;
    default        -> "something else";
  };
}
error: this case label is dominated by a preceding case label
    case Integer i -> "integer: " + i;
         ^

In the right order – the more specific type first, then the more general one – the method compiles:

String describe(Object obj) {
  return switch (obj) {
    case Integer i -> "integer: " + i;
    case Number n  -> "some other number: " + n;
    default        -> "something else";
  };
}

For 12, 3.14, and "Hello", it returns:

integer: 12
some other number: 3.14
something else

Three more rules follow from the same principle.

Case with Guard Before Case Without Guard

The case with a guard must come before the case without a guard. If case String s comes first, it matches every string, and the case with the guard after it is never reached:

String describe(Object obj) {
  return switch (obj) {
    case String s                     -> "string: " + s;
    case String s when s.length() > 5 -> "long string: " + s;
    default                           -> "something else";
  };
}
error: this case label is dominated by a preceding case label
    case String s when s.length() > 5 -> "long string: " + s;
         ^

In the right order, the switch checks the condition first and catches all other strings with the case without a guard:

String describe(Object obj) {
  return switch (obj) {
    case String s when s.length() > 5 -> "long string: " + s;
    case String s                     -> "string: " + s;
    default                           -> "something else";
  };
}

For "Hello, world", "Hello", and 12, the method returns:

long string: Hello, world
string: Hello
something else

Constant Before Pattern

A constant must come before the pattern of its type. case "Hello" after case String s is never reached, because every string already matches the pattern:

String greet(String text) {
  return switch (text) {
    case String s -> "text: " + s;
    case "Hello"  -> "Hello back";
  };
}
error: this case label is dominated by a preceding case label
    case "Hello"  -> "Hello back";
         ^

With the constant first, the method compiles:

String greet(String text) {
  return switch (text) {
    case "Hello"  -> "Hello back";
    case String s -> "text: " + s;
  };
}

greet("Hello") and greet("Hi") return:

Hello back
text: Hi

default After All Patterns

default must come after all patterns. If default comes before case String s, the compiler reports case String s as dominated:

String describe(Object obj) {
  return switch (obj) {
    default       -> "something else";
    case String s -> "string: " + s;
  };
}
error: this case label is dominated by a preceding case label
    case String s -> "string: " + s;
         ^

With default at the end, the method compiles:

String describe(Object obj) {
  return switch (obj) {
    case String s -> "string: " + s;
    default       -> "something else";
  };
}

For "Hello" and 12, it returns:

string: Hello
something else

Exhaustiveness: When a switch Needs No default

A switch with patterns must be exhaustive: for every possible value of the selector, a case must match. This applies to switch expressions and switch statements alike. The compiler rejects a statement over Object that only handles strings and integers:

void print(Object obj) {
  switch (obj) {
    case String s  -> IO.println("string: " + s);
    case Integer i -> IO.println("integer, squared: " + i * i);
  }
}
error: the switch statement does not cover all possible input values
  switch (obj) {
  ^
  missing patterns:
      Object _

A default is missing here. A switch statement that uses neither patterns nor case null, on the other hand, still does not need to be exhaustive – so that every existing switch statement compiles unchanged.

A switch gets by without default if its cases cover all possible types. That is possible with a sealed class hierarchy, because sealed classes determine which classes may implement an interface:

sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

double area(Shape shape) {
  return switch (shape) {
    case Circle c    -> Math.PI * c.radius() * c.radius();
    case Square s    -> s.side() * s.side();
    case Rectangle r -> r.width() * r.height();
  };
}

For a circle with radius 1, a square with side length 2, and a rectangle of 2 × 3, the method returns:

3.141592653589793
4.0
6.0

If one of the three cases is missing, e.g., Rectangle, the compiler reports:

error: the switch expression does not cover all possible input values
  return switch (shape) {
         ^
  missing patterns:
      Rectangle _

This is where the benefit lies: when a fourth shape is added, the compiler reports every switch over Shape that does not handle the fourth shape yet as incomplete – and so shows us the places in the code we have to adapt. A default case, in contrast, would silently catch the new shape – which is why I recommend leaving out default for sealed types.

Guards do not count toward exhaustiveness, because the compiler does not evaluate their conditions. It can’t, either: they depend on values that are only known at runtime.

The following three cases cover every number, yet the compiler rejects the switch:

String sign(Integer number) {
  return switch (number) {
    case Integer i when i < 0  -> "negative";
    case Integer i when i == 0 -> "zero";
    case Integer i when i > 0  -> "positive";
  };
}
error: the switch expression does not cover all possible input values
  return switch (number) {
         ^
  missing patterns:
      Integer _

Leave out the guard on the last case – case Integer i -> "positive" – and the switch is exhaustive.

Qualified Enum Constants

Up to Java 20, an enum constant could only appear in a switch over its own enum, and only with its simple name. A switch over the enum CompassDirection, for example, looked like this:

enum CompassDirection { NORTH, SOUTH, EAST, WEST }

String fly(CompassDirection direction) {
  return switch (direction) {
    case NORTH -> "Flying north";
    case SOUTH -> "Flying south";
    case EAST  -> "Flying east";
    case WEST  -> "Flying west";
  };
}

With the name of the enum in front, e.g., case CompassDirection.NORTH, the compiler – in Java 17, for example – reported “an enum switch case label must be the unqualified name of an enumeration constant”.

Since Java 21, an enum constant may also be qualified, that is, appear in the case with the name of its enum, e.g., case CompassDirection.NORTH. This lets you branch over a sealed interface that is implemented by several enums:

sealed interface Direction permits CompassDirection, VerticalDirection {}
enum CompassDirection implements Direction { NORTH, SOUTH, EAST, WEST }
enum VerticalDirection implements Direction { UP, DOWN }

String fly(Direction direction) {
  return switch (direction) {
    case CompassDirection.NORTH -> "Flying north";
    case CompassDirection.SOUTH -> "Flying south";
    case CompassDirection.EAST  -> "Flying east";
    case CompassDirection.WEST  -> "Flying west";
    case VerticalDirection.UP   -> "Gaining altitude";
    case VerticalDirection.DOWN -> "Losing altitude";
  };
}

fly(CompassDirection.EAST) and fly(VerticalDirection.UP) return:

Flying east
Gaining altitude

The selector has the type Direction – not an enum type, but a sealed interface that both enum types implement. Up to Java 20, this switch would therefore not have been possible. Since the switch covers all six constants of both enums, it is exhaustive and needs no default.

Pattern Variables in a switch: Scope and Fall-Through

A pattern variable is in scope in its case: in the guard and on the right-hand side of the arrow. Two cases may therefore declare variables with the same name.

In the colon notation, execution falls through to the next case without a break. It must not fall through into a case with a pattern, because its pattern variable would then have no value:

void print(Object obj) {
  switch (obj) {
    case Integer i:
      IO.println("integer: " + i);
    case String s:
      IO.println("string: " + s);
      break;
    default:
      IO.println("something else");
  }
}
error: illegal fall-through to a pattern
    case String s:
         ^
  (the previous case label is missing a break)

For the same reason, a case must not carry several patterns with variables. The compiler rejects case Integer i, Long l, because for an Integer, l would have no value, and for a Long, i would have none:

error: illegal fall-through from a pattern
    case Integer i, Long l -> "integral number";
                    ^

Since Java 22, this works with unnamed patterns, which bind no variable:

String classify(Number number) {
  return switch (number) {
    case Byte _, Short _, Integer _, Long _ -> "integral number";
    case Float _, Double _                  -> "floating-point number";
    default                                 -> "other number";
  };
}

classify(12), classify(3.14), and classify(new BigDecimal("1.5")) return:

integral number
floating-point number
other number

You can find more on the underscore in the article on unnamed variables and patterns.

Record Patterns

A record is a transparent carrier of its components: its constructor takes the components, its accessor methods hand them out. A record pattern makes use of this and takes a record apart into its components directly. Record patterns have been available since Java 21.

With a type pattern, you check, for example, whether an object is a Point and then call its accessor methods:

record Point(int x, int y) {}

void print(Object obj) {
  if (obj instanceof Point p) {
    IO.println("point at " + p.x() + "/" + p.y());
  }
}

With a record pattern, in contrast, you bind the components directly to variables (int x and int y in the following example):

record Point(int x, int y) {}

void print(Object obj) {
  if (obj instanceof Point(int x, int y)) {
    IO.println("point at " + x + "/" + y);
  }
}

For new Point(3, 4), both methods print:

point at 3/4

The record pattern Point(int x, int y) matches if obj is a Point. It then calls the accessor methods x() and y() and binds their values to the variables x and y. The components appear in the pattern in the same order as in the record’s declaration; you can choose their names in the pattern freely.

In a switch, you can combine record patterns with guards:

String describe(Point point) {
  return switch (point) {
    case Point(int x, int y) when x == 0 && y == 0 -> "origin";
    case Point(int x, int y) when x == y           -> "on the diagonal: " + x;
    case Point(int x, int y)                       -> "point at " + x + "/" + y;
  };
}

For new Point(0, 0), new Point(5, 5), and new Point(3, 4), the method returns:

origin
on the diagonal: 5
point at 3/4

A constant in the place of a component, such as case Point(0, int y), is not allowed; the compiler reports a syntax error. Conditions on the values of the components therefore go into the guard.

Nested Record Patterns

If a component is itself a record, you can take it apart in the same pattern, too. Without a record pattern, this looks as follows for a line made of two points:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

void print(Object obj) {
  if (obj instanceof Line line) {
    Point from = line.from();
    Point to = line.to();
    if (from != null && to != null) {
      IO.println("line from " + from.x() + "/" + from.y()
          + " to " + to.x() + "/" + to.y());
    }
  }
}

With a nested record pattern, this becomes much shorter:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

void print(Object obj) {
  if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
    IO.println("line from " + x1 + "/" + y1 + " to " + x2 + "/" + y2);
  }
}

For a line from Point(1, 2) to Point(5, 7), both methods print:

line from 1/2 to 5/7

The following diagram shows how the pattern takes the line apart. Each record pattern checks the type of its object and calls its accessor methods; the arrows carry the names of these methods. The inner patterns check their results, and the four type patterns in the bottom row bind the values to the variables:

Nested record pattern: the pattern Line(Point(int x1, int y1), Point(int x2, int y2)) takes a Line apart into two Points and binds x1 = 1, y1 = 2, x2 = 5, y2 = 7
A nested record pattern takes the line apart in one step, down to the four coordinates

The variant without a record pattern checks from and to for null because the variant with the record pattern does so, too: a record pattern never matches null. If from or to is null, the nested pattern does not match, and the method prints nothing.

var and Generics

Instead of the types of the components, you may write var. The compiler then derives the type from the record’s declaration – for a generic record, also from its type argument:

record Pair<T>(T first, T second) {}

void print(Pair<String> pair) {
  if (pair instanceof Pair(var first, var second)) {
    IO.println(first.toUpperCase() + " " + second.length());
  }
}

For new Pair<>("Hello", "world"), the method prints:

HELLO 5

The pattern Pair(var first, var second) carries no type argument. The compiler derives it from the type of pair – Pair<String> – and gives first and second the type String. That is why first.toUpperCase() and second.length() compile without a cast.

The Underscore: Unnamed Patterns

If you don’t need a component, then since Java 22, you can write an underscore _ in its place. The unnamed pattern matches any value of the component and binds no variable:

String start(Object obj) {
  return switch (obj) {
    case Line(Point(var x, var y), _) -> "line starting at " + x + "/" + y;
    default                           -> "not a line";
  };
}

For the line from the previous section, the method returns:

line starting at 1/2

The underscore also exists as a variable name in a type pattern, e.g., case Integer _. The article on unnamed variables and patterns explains both forms.

Record Patterns and null

A record pattern never matches null. A type pattern in a component, on the other hand, also matches null if it covers every possible value of the component – as Point to does for a component of type Point. The following listing checks null and a line whose end point is null against five patterns:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

void main() {
  Object nothing = null;
  IO.println(nothing instanceof Point(int x, int y));

  Line half = new Line(new Point(1, 2), null);
  IO.println(half instanceof Line(Point from, Point to));
  IO.println(half instanceof Line(var from, var to));
  IO.println(half instanceof Line(Point(int x1, int y1), Point to));
  IO.println(half instanceof Line(Point(int x1, int y1), Point(int x2, int y2)));
}
false
true
true
true
false

The five lines in order:

  1. null matches no record pattern.
  2. The type pattern Point to covers every value of the component to and therefore also matches null; to is then null.
  3. The pattern var to behaves like Point to.
  4. The start point is a Point and matches Point(int x1, int y1); the end point matches Point to.
  5. The end point is null and therefore does not match the record pattern Point(int x2, int y2).

So if you need the components of a record that can be null, a nested record pattern includes the null check.

When an Accessor Method Throws an Exception

A record pattern calls the record’s accessor methods. If one of them throws an exception, the switch – or instanceof – wraps it in a MatchException. The following listing overrides the accessor method celsius() so that it throws an exception for a missing measurement (represented as Double.NaN):

record Temperature(double celsius) {
  @Override
  public double celsius() {
    if (Double.isNaN(celsius)) {
      throw new IllegalStateException("not measured");
    }
    return celsius;
  }
}

String describe(Object obj) {
  return switch (obj) {
    case Temperature(double celsius) -> celsius + " °C";
    default                          -> "something else";
  };
}

describe(new Temperature(21.5)) returns 21.5 °C. For the missing measurement, i.e., describe(new Temperature(Double.NaN)), the following listing catches the exception and prints it together with its cause:

try {
  IO.println(describe(new Temperature(Double.NaN)));
} catch (MatchException e) {
  IO.println(e);
  IO.println("cause: " + e.getCause());
}
java.lang.MatchException: java.lang.IllegalStateException: not measured
cause: java.lang.IllegalStateException: not measured

The MatchException carries the original exception as its cause. It is safest if accessor methods throw no exceptions at all: check invalid values in the record’s constructor instead.

Putting It All Together: Sealed Interfaces, Records, and switch

Sealed interfaces, records, and switch with patterns complement each other: the sealed interface determines which records exist, the records carry the data, and the switch branches over them, checked for exhaustiveness by the compiler. The following example describes arithmetic expressions such as (2 + 3) * 4 as a tree of records:

sealed interface Expr permits Const, Neg, Add, Mul {}
record Const(int value) implements Expr {}
record Neg(Expr operand) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}

Const is a number, Neg the negation of an expression, and Add and Mul the sum and the product of two expressions. The expression (2 + 3) * 4 is therefore a Mul whose left operand is an Add:

Expression tree for (2 + 3) * 4: Mul with the operands Add(Const(2), Const(3)) and Const(4)
The expression (2 + 3) * 4 as a tree of records

To evaluate such a tree, the eval() method branches over the four records with a switch and calls itself for the operands. The format() method produces the expression as text in the same way:

int eval(Expr expr) {
  return switch (expr) {
    case Const(int value)           -> value;
    case Neg(Expr operand)          -> -eval(operand);
    case Add(Expr left, Expr right) -> eval(left) + eval(right);
    case Mul(Expr left, Expr right) -> eval(left) * eval(right);
  };
}

String format(Expr expr) {
  return switch (expr) {
    case Const(int value)           -> String.valueOf(value);
    case Neg(Expr operand)          -> "(-" + format(operand) + ")";
    case Add(Expr left, Expr right) ->
        "(" + format(left) + " + " + format(right) + ")";
    case Mul(Expr left, Expr right) -> format(left) + " * " + format(right);
  };
}

For the tree from the diagram – new Mul(new Add(new Const(2), new Const(3)), new Const(4)) – format(expr) + " = " + eval(expr) returns:

(2 + 3) * 4 = 20

Neither of the two switch expressions needs a default, because Expr is sealed and each of the four records has its case.

Simplifying Expressions with Nested Patterns

Nested patterns show their benefit when a method checks not only the top node but also its children. The following simplify() method simplifies an expression according to five rules:

  • A product with the left factor 0 becomes 0.
  • A product with the left factor 1 becomes the right factor.
  • A product with the right factor 1 becomes the left factor.
  • A sum with the left summand 0 becomes the right summand.
  • A double negation becomes the expression without negation.

Each rule is one case in the switch:

Expr simplify(Expr expr) {
  return switch (expr) {
    case Mul(Const(int c), Expr _) when c == 0 -> new Const(0);
    case Mul(Const(int c), Expr e) when c == 1 -> simplify(e);
    case Mul(Expr e, Const(int c)) when c == 1 -> simplify(e);
    case Add(Const(int c), Expr e) when c == 0 -> simplify(e);
    case Neg(Neg(Expr e))                      -> simplify(e);
    case Neg(Expr e)                           -> new Neg(simplify(e));
    case Add(Expr l, Expr r)                   -> new Add(simplify(l), simplify(r));
    case Mul(Expr l, Expr r)                   -> new Mul(simplify(l), simplify(r));
    case Const c                               -> c;
  };
}

The case Mul(Const(int c), Expr e) when c == 1, for example, matches a product whose left operand is the constant 1 and returns the simplified right operand. The last four cases have no guard and together cover all four records – this makes the switch exhaustive. Three of them simplify the operands; the fourth returns a constant unchanged.

simplify() reduces the expression 1 * -(-(0 + 7)) – as a tree, new Mul(new Const(1), new Neg(new Neg(new Add(new Const(0), new Const(7))))) – to 7 in a single pass:

1 * (-(-(0 + 7))) = 7
7 = 7

Pattern Matching Instead of the Visitor Pattern

Without pattern matching, there are two ways to branch over the records of the expression tree: an instanceof chain, which the compiler does not check for exhaustiveness, or the visitor pattern with a visitor interface, one visit() method per record, an accept() method in each record, and one visitor class per operation.

With pattern matching, each operation – eval(), format(), and simplify() – consists of a single method with a switch instead of a visitor class with four visit() methods. The records need no accept() method and remain pure data carriers.

If a fifth record is added, e.g., Div for division, the compiler reports the switch expressions in eval(), format(), and simplify() as incomplete. You then have to add a case for Div to all three methods before the code compiles again. For a hierarchy you control yourself, that is intended.

If, on the other hand, third-party code should be able to add new subtypes, the model does not fit: a sealed interface only allows the classes listed in its permits clause.

Primitive Types in Patterns (Preview)

Since Java 23, patterns also exist for primitive types, as a preview feature. In Java 27, it is in its fifth preview with JEP 532. A primitive type pattern such as byte b checks whether a value fits into the type without loss:

String fits(int value) {
  return switch (value) {
    case byte b  -> "fits into a byte: " + b;
    case short s -> "fits into a short: " + s;
    case int i   -> "needs an int: " + i;
  };
}

With --enable-preview in Java 27, the method returns for 100, 1_000, and 100_000:

fits into a byte: 100
fits into a short: 1000
needs an int: 100000

The article Primitive Types in Patterns, instanceof, and switch shows which rules apply to primitive types in detail and how they behave with wrapper classes.

Common Mistakes

The sections above show each of the following mistakes in detail:

  • The switch has no case null. If the selector is null, the switch throws a NullPointerException – even with default. A case null handles it.
  • The more general type comes before the more specific one. The compiler reports the later case as dominated; put the subtype before the supertype.
  • The last case has a guard. Guards do not count toward exhaustiveness; leave out the guard on the last case of a type.
  • A default in a switch over a sealed type. It silently catches new subtypes instead of letting the compiler report them.
  • A pattern variable to the right of ||. There, it has no value, and the compiler does not know it – the scope follows &&, not ||.
  • A constant in a record pattern. Point(0, int y) is not allowed (yet); the condition belongs in a guard with when.
  • An accessor method that throws an exception. A record pattern turns it into a MatchException.

History

Pattern matching is part of Project Amber and arrived in Java over several preview rounds:

FeaturePreviewsFinal
Pattern Matching for instanceofJava 14 (JEP 305),
Java 15 (JEP 375)
Java 16 (JEP 394)
Pattern Matching for switchJava 17 (JEP 406),
Java 18 (JEP 420),
Java 19 (JEP 427),
Java 20 (JEP 433)
Java 21 (JEP 441)
Record PatternsJava 19 (JEP 405),
Java 20 (JEP 432)
Java 21 (JEP 440)
Unnamed Variables & PatternsJava 21 (JEP 443)Java 22 (JEP 456)
Primitive Types in PatternsJava 23 (JEP 455),
Java 24 (JEP 488),
Java 25 (JEP 507),
Java 26 (JEP 530),
Java 27 (JEP 532)
–

Three forms from the previews no longer exist in the final version:

  • Guards with &&: In Java 17 and 18, the condition was attached to the pattern with &&, as in case String s && s.length() > 5. Since Java 19, it is when.
  • Named record patterns: In Java 19, a record pattern could additionally bind a variable for the whole record, as in Point(int x, int y) p. Java 20 removed this again.
  • Record patterns in the enhanced for loop: Java 20 allowed for (Point(int x, int y) : points). Java 21 dropped it, with the prospect of proposing it again in a future JEP.

The details of the individual rounds are in the articles on Java 14, Java 15, Java 16, Java 17, Java 18, Java 19, Java 20, and Java 21.

Summary

A pattern consists of a test and of variables to which it binds the matching parts of a value. Since Java 16, a type pattern such as String s can stand to the right of instanceof and replace the cast after it. The pattern variable is in scope where the pattern has certainly matched: in the if block, to the right of &&, and after an if that leaves the method when the pattern does not match.

Since Java 21, a switch can branch over patterns. A guard with when narrows a case, and a case null handles null, which otherwise leads to a NullPointerException. The compiler reports cases that an earlier case hides, and a switch with patterns that does not cover all possible values.

Record patterns such as Point(int x, int y) take a record apart into its components, also nested and with var. A record pattern never matches null. Since Java 22, an underscore can replace the components you don’t need.

With sealed classes, the compiler can check whether a switch handles every subtype. When a subtype is added, it reports every switch that does not handle it yet as incomplete.

My recommendations:

  • Write instanceof with a pattern instead of a subsequent cast – in any code from Java 16 on.
  • When you check a value against several types, use a switch with patterns instead of an if chain. The compiler then checks order and exhaustiveness.
  • Leave out default for sealed types so that the compiler reports new subtypes.
  • Write case null if the selector can be null – otherwise, the switch throws a NullPointerException.
  • Put conditions on components into a guard with when, not into an if to the right of the arrow. Then the switch checks the next case when the condition is not met.

The article Primitive Types in Patterns, instanceof, and switch shows how patterns for primitive types continue.

What should I cover next? The best way for me to find out is through your feedback: a review on my ProvenExpert profile shows me which topics matter to you – and motivates me to write more articles.

👉 Leave a review

This subject in your own code?

You have read the article – in the training your team works with it. Over 2 days we go through the subjects on your own projects instead of constructed examples.

Hands-on, easy to understand, and directly applicable to your day-to-day project work. Instead of theory, I teach principles that help you write code that is better, more maintainable, and more performant in the long run.

Java 21 TrainingSee all trainings

Become a Better Java Developer

My free newsletter keeps you ahead. Modern Java: new versions & features, performance, and JVM insights – once a month.

Search