Disclaimer: This answer only extends the other answers by summarizing some implications and giving some examples. You should not make any decisions relying on this information because pattern matching and value types are still subjects of change.
There are two interesting documents about data classes aka records vs value types:
The older version from February 2018
http://cr.openjdk.java.net/~briangoetz/amber/datum_2.html#are-data-classes-the-same-as-value-types
and the newer version from February 2019
https://cr.openjdk.java.net/~briangoetz/amber/datum.html#are-records-the-same-as-value-types
Each document includes a paragraph about differences between records and value types.
The older version says
The lack of layout polymorphism means we have to give up something else: self-reference. A value type V cannot refer, directly or indirectly, to another V.
Moreover,
Unlike value types, data classes are well suited to representing tree and graph nodes.
However,
But value classes need not give up any encapsulation, and in fact encapsulation is essential for some applications of value types
Let's clarify that:
You won't be able to implement node-based composited data structures like linked lists or hierarchical trees with value types. However, you can use the value types for the elements of those data structures.
Moreover, value types support some forms of encapsulation in opposite to records which don't at all. This means you can have additional fields in value types which you haven't defined in the class header and which are hidden to the user of the value type. Records can't do that because their representation is restricted to their API, i.e. all their fields are declared in the class header (and only there!).
Let's have some examples to illustrate all that.
E.g. you will be able to create composited logical expressions with records but not with value types:
sealed interface LogExpr { boolean eval(); }
record Val(boolean value) implements LogExpr {}
record Not(LogExpr logExpr) implements LogExpr {}
record And(LogExpr left, LogExpr right) implements LogExpr {}
record Or(LogExpr left, LogExpr right) implements LogExpr {}
This will not work with value types because this demands the ability of self-references of the same value type.
You want to be able to create expressions like "Not(Not(Val(true)))".
E.g. you can also use records to define the class Fraction:
record Fraction(int numerator, int denominator) {
Fraction(int numerator, int denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("Denominator cannot be 0!");
}
}
public double asFloatingPoint() { return ((double) numerator) / denominator; }
// operations like add, sub, mult or div
}
What about calculating the floating point value of that fraction?
You can add a method asFloatingPoint() to the record Fraction.
And it will always calculate (and recalculate) the same floating point value each time it is invoked.
(Records and value types are immutable by default).
However, you cannot precalculate and store the floating point value inside this record in a way hidden to the user.
And you won't like to explicitly declare the floating point value as a third parameter in the class header.
Luckily, value types can do that:
inline class Fraction(int numerator, int denominator) {
private final double floatingPoint;
Fraction(int numerator, int denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("Denominator cannot be 0!");
}
floatingPoint = ((double) numerator) / denominator;
}
public double asFloatingPoint() { return floatingPoint; }
// operations like add, sub, mult or div
}
Of course, hidden fields can be one reason why you want to use value types.
They are only one aspect and probably a minor one though.
If you create many instances of Fraction and maybe store them in collections,
you will benefit a lot from the flattened memory layout.
That's definitely a more important reason to prefer value types to records.
There are some situations where you want to benefit from both records and value types.
E.g. you might want to develop a game in which you move your piece through a map.
You saved a history of moves in a list some time ago where every move stores a number of steps into one direction. And you want to compute the next position dependending on that list of moves now.
If your class Move is a value type, then the list can use the flattened memory layout.
And if your class Move also is a record at the same time you can use pattern matching without the need of defining an explicit deconstruction pattern.
Your code can look like that:
enum Direction { LEFT, RIGHT, UP, DOWN }´
record Position(int x, int y) { }
inline record Move(int steps, Direction dir) { }
public Position move(Position position, List<Move> moves) {
int x = position.x();
int y = position.y();
for(Move move : moves) {
x = x + switch(move) {
case Move(var s, LEFT) -> -s;
case Move(var s, RIGHT) -> +s;
case Move(var s, UP) -> 0;
case Move(var s, DOWN) -> 0;
}
y = y + switch(move) {
case Move(var s, LEFT) -> 0;
case Move(var s, RIGHT) -> 0;
case Move(var s, UP) -> -s;
case Move(var s, DOWN) -> +s;
}
}
return new Position(x, y);
}
Of course, there are many other ways to implement the same behavior.
However, records and value types give you some more options for implementations which can be very useful.