Record Patterns (JEP 440)
This article, updated for Java 21 and also posted on http://javaalmanac.io, describes record patterns. A record pattern lets you “deconstruct” a record value, binding each component to a variable. Record patterns work withinstanceof
andswitch
pattern matching. Guards are supported. They are particularly compelling with nested deconstruction and sealed record hierarchies.
Deconstructing a Record
Java 21 has two public record
classes. Here is one of them:
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 withu
andg
}
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 withu
andg
}
Instead of var
, you can also use the actual types of the components:
if (obj instanceof UnixDomainPrincipal(UserPrincipal u, DomainPrincipal g)) { // Do something withu
andg
}
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 withu
andg
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. (A single underscore is a Java keyword, held in reserve for future use.)
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 = Pair.of("Hello", "World"); if (p instanceof Pair(var a, var b)) System.out.println(a + " " + b.toUpperCase());
In the pattern, the type argument is inferred; here, as Pair<String>
. You can also specify it explicitly:
p instanceof Pair<String>(var a, var b)
or
p instanceof Pair<String>(String a, String b)
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 sum(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 }; }
Now the call
sum(Pair.of(new JSONNumber(42), new JSONString("Fred")))
no longer compiles, since there are no matching types for T
and U
.
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.
Trying to use explicit type arguments does not work:
public static <T extends JSONPrimitive<U>, U> Object sum(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 MatchError
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 }
Sincenull
does not match the innerBox(c)
, theswitch
statement throws aMatchException
, not aNullPointerException
.
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 API 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.
Comments powered by Talkyard.