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
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.
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.
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:
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.