Looking at Java 21: Record Patterns

 ยท 7 min
Pixabay on Pexels

Pattern matching is a declarative and composable approach that creates more powerful and expressive code for data structure navigation and processing. Java 16 added pattern matching for the instanceof operator (JEP 394), and we looked at pattern matching for switch in this series before (Switch Pattern Matching).

Today, it’s time to take a quick look at another kind of pattern matching: Record Patterns (JEP 440).


More Than Just Type Matching

So far, pattern matching in Java is mainly restricted to matching types:

java
// BEFORE JAVA 16
if (obj instanceof String) {
  String str = (String) obj;
  System.out.println(str);
}

// JAVA 16+
if (obj instanceof String str) {
  System.out.println(str);
}

Java 21 extended the concept to be usable in switch statements and expressions:

java
// BEFORE JAVA 21
static String asStringValue(Object anyValue) {
  String result = null;

  if (anyValue instanceof String str) {
    result = str;
  } else if (anyValue instanceof BigDecimal bd) {
    result = bd.toEngineeringString();
  } else if (anyValue instance Integer i) {
    result = Integer.toString(i);
  } else {
    result = "n/a";
  }

  return result;
}

// JAVA 21+
static String asStringValue(Object anyValue) {
  return switch (anyValue) {
    case String str    -> str;
    case BigDecimal bd -> bd.toEngineeringString();
    case Integer i     -> Integer.toString(i);
    default            -> "n/a";
  };
}

As you can see, the resulting code looks nicer and is more streamlined than before. But matching types is only one possible use case for pattern matching.

Deconstructing Records

Records are a special purpose class to easily aggregate data in a shallowly immutable fashion. They’re structured around components, similar to fields in a POJO or JavaBean. Their accessors, the “all components” (canonical) constructor, and Object-related helper method (toString, equals, hashCode) are all available with sensible implementations without requiring any additional code.

If you want to learn more about Records, you could check out my book “A Functional Approach to Java”, which discusses the topic on over 30 pages.

Record pattern matching is a way to match the Record’s type and access its components in a single step. Imaging a simple Record representing a 2-dimensional point:

java
record Point(int x, int y) {
  // no body
}

Matching its type and accessing one of its components looks like this:

java
Object maybePoint = ...;

if (maybePoint instanceof Point p) {
  System.out.println("Point => " + p.x() + "/" + p.y());
}

To not match the Point but its components, they must be explicitly stated in the pattern:

java
Object maybePoint = ...;

if (maybePoint instanceof Point(int x, int y)) {
  System.out.println("Point => " + x + "/" + y);
}

If you’re like me when I first looked at the feature, you might think: “ok, but how is that supposed to be better/simpler than before?”

Well, in a certain sense, this way of thinking is correct. Repeating the Record’s definition to access its components seems tedious. But if we look further than such a simple example, the potential of what Record pattern matching can do for us will reveal itself!

Nested Records

Deconstructing a simple Record doesn’t have much of an advantage, in my opinion, at least without a feature I’m going to discuss shortly. The real power of deconstructing Records is found if a Record contains another Record.

Let’s design a Record representing a window frame, including its origin and size on the screen:

java
record Size(int width, int height) { }
record Point(int x, int y) { }
record WindowFrame(Point origin, Size size) { }

To access the height component of a WindowFrame in the nested Size component, we’d need multiple matches:

java
if (obj instanceof WindowFrame wf) {
  if (wf.size() != null) {
    System.out.println("Height: " + wf.size().height());
  }
}

It doesn’t become that much better with deconstruction:

java
if (obj instanceof WindowFrame(Point origin, Size size)) {
  if (size != null) {
    System.out.println("Height: " + size.height());
  }
}

However, the deconstruction of Records can be nested, eliminating the need for the null check and making the code more reasonable in the process:

java
if (obj instanceof WindowFrame(Point origin, Size(int width, int height))) {
    System.out.println("Height: " + height);
}

The difference here is that a simple WindowFrame(Point origin, Size size) matches even if Size size is null. When you deconstruct Size, too, though, it only matches if size isn’t null.

In essence, either the full pattern matches or none of it.

Simpler Patterns with Type Inference

Requiring the full Records declaration for destructuring feels like a chore. The components must match, or the compiler won’t be happy:

java
record Point(int x, int y) { }

if (obj instanceof Point(long x, int y)) {
  // ...
}

// Error:
// incompatible types: pattern of type long is not applicable at int
// if (obj instanceof Point(long x, int y)) {
//                          ^----^

As the required components are fixed by the Record’s type, we can use local variable type inference by replacing the actual component types with the var keyword:

java
if (obj instanceof Point(var x, var y)) {
  // ...
}

This also won’t break the code if a Record’s component type changes. On the other hand, an explicit type declaration might be more expressive, and I’m sure that IDEs will help out completing the components in a future release.

Even Simpler Patterns with JEP 443

Another upcoming feature only available as a preview in Java 21 is JEP 443: Unnamed Patterns and Variables.

This feature improves readability throughout our code by allowing us to replace an unused variable with _ (underscore). No more @SuppressWarnings("unused") needed to shut up all those pesky warnings!

Nameless variables are quite useful in many scenarios, like the Exception variable in a catch block or side-effect-only constructs:

java
try {
  //...
} catch (Exception _) {
  // we don't need the actual exception
}

int acc = 0;
for (Order _ : orders) {
    if (acc < LIMIT) { 
      // the actual order is not used
    }
}

Regarding Record pattern matching, this JEP simplifies (nested) calls as much as possible:

java
if (obj instanceof WindowFrame(_, Size(_, int height))) {
    System.out.println("Height: " + height);
}

Now, the pattern is reduced to just what’s needed to match, with less surrounding noise.


Conclusion

Pattern matching is a feature that was a long time absent in Java, or only available in a minuscule form compared to other languages. Adding Record destructuring is another great addition to narrow the feature gap and improve Java’s foundation further.

Deconstructing Record-based data structures using a more straightforward approach to navigate them leads to more reasonable and cleaner code, especially with nested Records. However, to be honest, at first, I didn’t see much of an advantage of Record pattern matching, especially compared to the impact of other features in Java 21.

As I tried to understand the benefits better (by writing an article about it), I didn’t like it much, especially the kind of repetitive syntax. But the more I played around with it, it became clearer that this won’t be an “everyday” feature, like Records themselves, at least for me.

Still, I believe it’s an interesting and worthwhile addition to the language, so give it a try (and some time), you might like it! And with upcoming features like unnamed patterns (JEP 443), Record pattern matching becomes even better!


A Functional Approach to Java Cover Image
Interested in using functional concepts and techniques in your Java code?
Check out my book!
Available in English, Polish, and soon, Chinese.

Resources

Looking at Java 21