The Java Version Almanac
javaalmanac.io
Feedback on this page?

Record Patterns (JEP 440)

Record patterns, previewed in JEP 405 and JEP 432, and finalized in JEP 440, let you “deconstruct” record values, binding components to variables. Record patterns work with instanceof and switch pattern matching. Guards are supported. They are particularly compelling with nested deconstruction and sealed record hierarchies.

Deconstructing a Record

Java 19 introduced this public record:

public record UnixDomainPrincipal(UserPrincipal user, GroupPrincipal group)

Suppose you have an Object that might just be an instance thereof. Then you can take it apart like this:

if (obj instanceof UnixDomainPrincipal(var u, var g)) {
   // Do something with u and g
}

Here, UnixDomainPrincipal(var u, var g) is a record pattern. If the scrutinee (that is, the value to be matched) is an instance of the record, then the variables u and g in the pattern are bound to the record components. The code is equivalent to

if (obj instanceof UnixDomainPrincipal p) {
   var u = p.user();
   var g = p.group();
   // Do something with u and g
}

Instead of var, you can also use the actual types of the components:

if (obj instanceof UnixDomainPrincipal(UserPrincipal u, DomainPrincipal g)) {
   // Do something with u and g
}

Either way, the syntax is meant to remind you of variable declarations.

You can also use a record pattern in switch:

switch (obj) {
   case UnixDomainPrincipal(var u, var g):
      // Do something with u and g
      break;
   default:
      break;
}

That’s potentially nice, but how often does it happen that you have an Object that might be a record instance? To see more interesting examples, we need multiple record classes. It gets even better when they extend a sealed interface because then the switch can test for exhaustiveness.

A Sealed Record Family

Ever since Java 1.4, there has been a CharSequence interface with methods

char charAt(int index);
int length();
CharSequence subSequence(int start, int end);
String toString();

Java 8 added a couple of default methods, and Java 11 a static method. We ignore them for this example.

The interface is implemented by StringBuilder, the legacy StringBuffer, java.nio.CharBuffer, a Swing class, and of course String. It is mostly used to write code that works with both String and StringBuilder.

We want to manipulate subsequences. They could touch the beginning or the end, or they lie in the middle. This is where we get a sealed interface and three records:

sealed interface SubSequence extends CharSequence permits Initial, Final, Middle { /* ... */ }

record Initial(CharSequence seq, int end) implements SubSequence { /* ... */ }
record Final(CharSequence seq, int start) implements SubSequence { /* ... */ }
record Middle(CharSequence seq, int start, int end) implements SubSequence { /* ... */ }

Of course, we need to implement the CharSequence methods. That’s easily done in the superinterface with a pattern match:

default int length() {
   return switch (this) {
      case Initial(var __, var end) -> end;
      case Final(var seq, var start) -> seq.length() - start();
      case Middle(var __, var start, var end) -> end - start;
   };
}

No default is required because we provided cases for all classes that implement the sealed interface.

Note the double underscore for the variables that we don’t care about. Once JEP 443 is no longer a preview feature, we can use a single underscore.

The following sandbox contains the complete example. Note that a record pattern can have a guard:

case Initial(var seq, var end) when s == 0
import java.util.*; sealed interface SubSequence extends CharSequence permits Initial, Final, Middle { CharSequence seq(); default int start() { return 0; } default int end() { return seq().length(); } default char charAt(int index) { Objects.checkIndex(index, length()); return seq().charAt(start() + index); } default int length() { return switch (this) { case Initial(var __, var end) -> end; case Final(var seq, var start) -> seq.length() - start(); case Middle(var ___, var start, var end) -> end - start; }; } default CharSequence subSequence(int s, int e) { return switch (this) { case Initial(var seq, var end) when s == 0 -> new Initial(seq, e); case Final(var seq, var start) when start + e == seq.length() -> new Final(seq, start + s); default -> new Middle(seq(), start() + s, start() + e); }; } } record Initial(CharSequence seq, int end) implements SubSequence { public Initial { Objects.checkIndex(end, seq.length()); } public String toString() { return seq.subSequence(0, end).toString(); } } record Final(CharSequence seq, int start) implements SubSequence { public Final { Objects.checkIndex(start, seq.length()); } public String toString() { return seq.subSequence(start, seq.length()).toString(); } } record Middle(CharSequence seq, int start, int end) implements SubSequence { public Middle { Objects.checkFromToIndex(start, end, seq.length()); } public String toString() { return seq.subSequence(start, end).toString(); } } public class Main { public static void main(String[] args) { CharSequence seq = new Final("Mississippi", 6); System.out.println(seq.length()); System.out.println(seq.subSequence(0, 3)); System.out.println(seq.subSequence(0, 3).getClass().getName()); } }

Nested Matches

Since Initial, Middle, and Final are themselves CharSequence instances, one can take, for example, the Final of an Initial of a sequence. Such a nesting can be simplified to a Middle of the original sequence:

static CharSequence simplify(SubSequence seq) {
   return switch (seq) {
      // ...
      case Initial(Final(var cs, var s1), var e2) ->
         new Middle(cs, s1, s1 + e2);
      // ...
   }
}

Note the convenient nested match that describes exactly the structure that we want to target.

You can only match nested record patterns, not values. For example, the following are forbidden:

case Final(var cs, 0) -> ...; // Error
case Final(null, var e) -> ...; // Error

A switch can match 0 or null at the top level, but not when it is nested. Instead, use a guard:

case Final(var cs, var s) when s == 0 -> cs;

This sandbox has the complete definition of the simplify method. The details are fussy, but have a look at the overall structure and the elegance of the variable extraction, guards, and pattern nesting.

public class Main { public static CharSequence simplify(SubSequence seq) { return switch (seq) { case Initial(var cs, var e) when e == cs.length() -> cs; case Final(var cs, var s) when s == 0 -> cs; case Middle(var cs, var s, var e) when s == 0 && e == cs.length() -> cs; case Initial(Initial(var cs, var e1), var e2) -> new Initial(cs, e2); case Initial(Middle(var cs, var s1, var e1), var e2) -> new Middle(cs, s1, s1 + e2); case Initial(Final(var cs, var s1), var e2) -> new Middle(cs, s1, s1 + e2); case Middle(Initial(var cs, var e1), var s2, var e2) -> new Middle(cs, s2, e2); case Middle(Middle(var cs, var s1, var e1), var s2, var e2) -> new Middle(cs, s1 + s2, s1 + e2); case Middle(Final(var cs, var s1), var s2, var e2) -> new Middle(cs, s1 + s2, s1 + e2); case Final(Initial(var cs, var e1), var s2) -> new Middle(cs, s2, e1); case Final(Middle(var cs, var s1, var e1), var s2) -> new Middle(cs, s1 + s2, e1); case Final(Final(var cs, var s1), var s2) -> new Final(cs, s1 + s2); default -> seq; }; } public static void main(String[] args) { var result = simplify(new Final( new Initial("Mississippi", 6), 3)); System.out.println(result + " " + result.getClass().getName()); } } import java.util.*; public sealed interface SubSequence extends CharSequence permits Initial, Final, Middle { CharSequence seq(); default int start() { return 0; } default int end() { return seq().length(); } default char charAt(int index) { Objects.checkIndex(index, length()); return seq().charAt(start() + index); } default int length() { return switch (this) { case Initial(var __, var end) -> end; case Final(var seq, var start) -> seq.length() - start(); case Middle(var __, var start, var end) -> end - start; }; } default CharSequence subSequence(int s, int e) { return switch (this) { case Initial(var seq, var end) when s == 0 -> new Initial(seq, e); case Final(var seq, var start) when start + e == seq.length() -> new Final(seq, start + s); default -> new Middle(seq(), start() + s, start() + e); }; } } record Initial(CharSequence seq, int end) implements SubSequence { public Initial { Objects.checkIndex(end, seq.length()); } public String toString() { return seq.subSequence(0, end).toString(); } } record Final(CharSequence seq, int start) implements SubSequence { public Final { Objects.checkIndex(start, seq.length()); } public String toString() { return seq.subSequence(start, seq.length()).toString(); } } record Middle(CharSequence seq, int start, int end) implements SubSequence { public Middle { Objects.checkFromToIndex(start, end, seq.length()); } public String toString() { return seq.subSequence(start, end).toString(); } }

Generics

Here is a generic record:

record Pair<T>(T first, T second) {
   public static <U> Pair<U> of(U first, U second) { return new Pair<U>(first, second); }
}

Now you can form a record pattern:

var p = new Pair<String>("Hello", "World");
if (p instanceof Pair(var a, var b)) System.out.println(a + " " + b.toUpperCase());

The type argument of Pair is inferred; here, as Pair<String>. You can also specify it explicitly:

if (p instanceof Pair<String>(var a, var b)) ...
if (p instanceof Pair<String>(String a, String b)) ...

Keep in mind that the type arguments are only used by the compiler. At runtime, generic types are erased, and the instanceof expression only checks whether p is a raw Pair.

When generic types are involved, the compiler may need to work pretty hard to verify exhaustiveness. Consider this incomplete hierarchy of JSON types:

sealed interface JSONValue {}
sealed interface JSONPrimitive<T> extends JSONValue {}
record JSONNumber(double value) implements JSONPrimitive<Double> {}
record JSONBoolean(boolean value) implements JSONPrimitive<Boolean> {}
record JSONString(String value) implements JSONPrimitive<String> {}

The switch in the following method is exhaustive:

public static <T> double toNumber(JSONPrimitive<T> v) {
   return switch (v) {
      case JSONNumber(var n) -> n;
      case JSONBoolean(var b) -> b ? 1 : 0;
      case JSONString(var s) -> {
         try {
            yield Double.parseDouble(s);
         } catch (NumberFormatException __) {
            yield Double.NaN;
         }
      }
   };
}

At first glance, it appears as if there might be an unbounded number of classes implementing JSONPrimitive<T>, but the compiler can track than there are only three of them.

Conversely, the compiler can tell that this switch is not exhaustive:

public static Object sum1(Pair<? extends JSONPrimitive<?>> pair) {
   return switch (pair) {
      case Pair<?>(JSONNumber(var left), JSONNumber(var right)) -> left + right;
      case Pair<?>(JSONBoolean(var left), JSONBoolean(var right)) -> left | right;
      case Pair<?>(JSONString(var left), JSONString(var right)) -> left.concat(right);
      // Compiler detects that the switch is not exhaustive
   };
}

After all, it would be possible to call this method as

sum1(Pair.of(new JSONNumber(42), new JSONString("Fred")))

The compiler notices that these mixed pairs are not covered. That is good.

Here is how to only accept homogeneous pairs:

public static <T extends JSONPrimitive<U>, U> Object sum2(Pair<T> pair) {
   return switch (pair) {
      case Pair(JSONNumber(var left), JSONNumber(var right)) -> left + right;
      case Pair(JSONBoolean(var left), JSONBoolean(var right)) -> left | right;
      case Pair(JSONString(var left), JSONString(var right)) -> left.concat(right);
      default -> throw new AssertionError(); // Sadly Java can't tell this won't happen
   };
}

Unfortunately, the default clause is necessary to make the switch exhaustive. In theory, there is enough information to determine that the pair components must be instances of the same type, but the Java type system can’t prove it.

Note that using explicit type arguments does not work.

public static <T extends JSONPrimitive<U>, U> Object sum3(Pair<T> pair) {
   return switch (pair) {
      // ERROR—unsafe casts
      case Pair<JSONNumber>(JSONNumber(var left), JSONNumber(var right)) -> left + right;
      case Pair<JSONBoolean>(JSONBoolean(var left), JSONBoolean(var right)) -> left | right;
      case Pair<JSONString>(JSONString(var left), JSONString(var right)) -> left.concat(right);
   };
}

The Java compiler does not know how to prove that the cast from Pair<T> to Pair<JSONNumber> is safe when the components have type JSONNumber.

Here is a sandbox so that you can play with the code of this section.

sealed interface JSONValue {} sealed interface JSONPrimitive<T> extends JSONValue {} record JSONNumber(double value) implements JSONPrimitive<Double> {} record JSONBoolean(boolean value) implements JSONPrimitive<Boolean> {} record JSONString(String value) implements JSONPrimitive<String> {} record Pair<T>(T left, T right) { public static <U> Pair<U> of(U left, U right) { return new Pair<U>(left, right); } } public class Main { public static <T> double toNumber(JSONPrimitive<T> v) { return switch (v) { case JSONNumber(var n) -> n; case JSONBoolean(var b) -> b ? 1 : 0; case JSONString(var s) -> { try { yield Double.parseDouble(s); } catch (NumberFormatException __) { yield Double.NaN; } } }; } public static Object sum1(Pair<? extends JSONPrimitive<?>> pair) { return switch (pair) { case Pair(JSONNumber(var left), JSONNumber(var right)) -> left + right; case Pair(JSONBoolean(var left), JSONBoolean(var right)) -> left | right; case Pair(JSONString(var left), JSONString(var right)) -> left.concat(right); // Compiler correctly detects that the switch is not exhaustive // Comment out the following line to verify default -> null; }; } public static <T extends JSONPrimitive<U>, U> Object sum2(Pair<T> pair) { return switch (pair) { case Pair(JSONNumber(var left), JSONNumber(var right)) -> left + right; case Pair(JSONBoolean(var left), JSONBoolean(var right)) -> left | right; case Pair(JSONString(var left), JSONString(var right)) -> left.concat(right); default -> throw new AssertionError(); // Sadly Java can't tell this won't happen }; } /* public static <T extends JSONPrimitive<U>, U> Object sum3(Pair<T> pair) { return switch (pair) { // Error—these generic types do not match Pair<T> case Pair<JSONNumber>(JSONNumber(var left), JSONNumber(var right)) -> left + right; case Pair<JSONBoolean>(JSONBoolean(var left), JSONBoolean(var right)) -> left | right; case Pair<JSONString>(JSONString(var left), JSONString(var right)) -> left.concat(right); }; } */ public static void main(String[] args) { System.out.println(toNumber(new JSONString("42"))); System.out.println(sum2(Pair.of(new JSONNumber(29), new JSONNumber(13)))); // This won't compile, and it shouldn't // System.out.println(sum2(Pair.of(new JSONNumber(29), new JSONString("13")))); } }

Match Exceptions

Consider a record pattern match:

switch (cs) {
   case Initial(var s, var n) -> ...
   ...
}

When this code runs, it makes an instanceof test, a cast, and then invokes the record’s component methods:

if (cs instanceof Initial) { 
   var s = ((Initial) cs).seq()
   var n = ((Initial) cs).end()
   . . .
}

What if those methods throw an exception?

In that case, the switch throws a MatchException whose cause is that exception. Check it out in this sandbox:

import java.util.*; sealed interface SubSequence extends CharSequence permits Initial, Final, Middle { CharSequence seq(); default int start() { return 0; } default int end() { return seq().length(); } default char charAt(int index) { Objects.checkIndex(index, length()); return seq().charAt(start() + index); } default int length() { return switch (this) { case Initial(var __, var end) -> end; case Final(var seq, var start) -> seq.length() - start(); case Middle(var ___, var start, var end) -> end - start; }; } default CharSequence subSequence(int s, int e) { return switch (this) { case Initial(var seq, var end) when s == 0 -> new Initial(seq, e); case Final(var seq, var start) when start + e == seq.length() -> new Final(seq, start + s); default -> new Middle(seq(), start() + s, start() + e); }; } } record Initial(CharSequence seq, int end) implements SubSequence { public int end() { Objects.checkIndex(end, seq.length()); return end; } public String toString() { return seq.subSequence(0, end).toString(); } } record Final(CharSequence seq, int start) implements SubSequence { public int start() { Objects.checkIndex(start, seq.length()); return start; } public String toString() { return seq.subSequence(start, seq.length()).toString(); } } record Middle(CharSequence seq, int start, int end) implements SubSequence { public int start() { Objects.checkFromToIndex(start, end, seq.length()); return start; } public int end() { Objects.checkFromToIndex(start, end, seq.length()); return end; } public String toString() { return seq.subSequence(start, end).toString(); } } public class Main { public static void main(String[] args) { CharSequence seq = new Middle("Mississippi", 5, 30); System.out.println(seq.length()); } }

Rémi Forax has a more amusing example which generates a linked list of match errors.

I think it is best not to override component accessors so that they throw exceptions. It is ok to throw an exception in the constructor when construction parameters are null or out of range. But once the record instance is constructed, one should be able to extract its state, no matter what it is.

null

No discussion of Java pattern matching would be complete without talking about null. As always in Java, instanceof is null-friendly and switch is null-hostile (unless there is a case null):

record Box<T>(T contents) { }
...
Box<String> b = null;
if (b instanceof Box(c)) ... // instanceof yields false

switch (b) { // throws NullPointerException
  case Box(c): ...; 
}

What about a boxed null?

b = new Box<String>(null);
if (b instanceof Box(c)) ... // instanceof yields true, c is null

switch (b) { 
  case Box(c): ...; // matches, c is null
}

Now consider this subtly different situation, with nested records where null appears in the middle:

Box<Box<String>> bb = new Box<Box<String>>(null);
if (bb instanceof Box(Box(c))) ... // instanceof yields false

switch (bb) { 
  case Box(Box(c)): ...; // throws MatchException
}

Since null does not match the inner Box(c), the switch statement throws a MatchException, not a NullPointerException.

Also note that you cannot use:

case Box(null):

To guard against this situation, you need:

case Box(c) when c == null: 

How Momentous Are Record Patterns?

Records are nice when they match your needs—a class that describes immutable objects that are “just data”. As of Java 21, the JDK has two of them. Of course, they are a recent feature, so surely there will be more to come. You probably have a few classes in your code base that would work well as records, and maybe you have started declaring your own.

For record patterns to be useful, you need multiple records that implement a common interface. You saw a nontrivial example with the SubSequence records. Another example is an expression hierarchy with records Sum, Difference, Product, Quotient. Or a functional list or tree with a record for the nodes. These examples are, depending on your point of view, foundational or academic. Either way, they are unlikely to feature prominently in your business logic.

Record patterns are a piece of the pattern matching toolset that Java is building up. Useful libraries may emerge in the future that make good use of them. Even more so when Java supports value types. A JSON library would be a plausible example. Keep record patterns on your radar, but pay no attention to those who tell you that they are a crucial reason to update your JDK.

References