Java 16 Records Tutorial with Examples
Introduction
A large share of Java classes exist only to carry data - a point with x and y coordinates, a customer with a name and an email, a row returned from a query. Writing such a class properly means private final fields, a constructor, getters, and correct
OUTPUT of the above code
From
Compact constructors for validation
Records are meant to be valid by construction. To check or normalize the components, you write a compact constructor - a constructor with no parameter list. Its parameters are implicit, and the fields are assigned automatically at its end, so it contains only the checks -
OUTPUT of the above code
Assigning to the parameter
OUTPUT of the above code
Since a record's fields are final, "modifying" methods like
Local records
Records can be declared inside a method. A local record is ideal for an intermediate result in a calculation, such as a pair of values in a stream pipeline, which previously needed an awkward
OUTPUT of the above code
Restrictions
Records are deliberately limited, so that a record is always just its data -
OUTPUT on compiling the above code
Records also work well with other modern Java features: they can implement sealed interfacesClick to read tutorial: Java 17 Sealed Classes Tutorial with Examples, and be taken apart with record patternsClick to read: record patterns in Java 21.
Summary
In this tutorial we saw how records, final in Java 16, declare data-carrier classes in a single line, with the fields, canonical constructor, accessors,
equals(), hashCode() and toString() methods - dozens of lines of boilerplate which hide the one thing that matters, the data. Records, previewed in Java 14 and 15 and finalized in Java 16Click to read tutorial: Overview of New Features in Java 16 (JEP 395), let you declare such a class in a single line. This tutorial explains records in Java with examples. We will declare and use a record, see what the compiler generates, validate data with compact constructors, add methods and implement interfaces, use local records, and go through the restrictions that make a record a record.
Declaring a record
A record declaration lists its components - the data it carries - in parentheses after its name -
A record and what the compiler generates
//RecordBasics.java
import java.util.HashSet;
import java.util.Set;
public class RecordBasics {
record Point(int x, int y) { }
public static void main(String[] args) {
Point p = new Point(3, 4);
System.out.println(p); // generated toString()
System.out.println("x = " + p.x() + ", y = " + p.y()); // generated accessors
Point q = new Point(3, 4);
System.out.println("p.equals(q): " + p.equals(q)); // generated equals(): compares components
System.out.println("same hashCode: " + (p.hashCode() == q.hashCode()));
Set<Point> points = new HashSet<>(Set.of(p, new Point(0, 0)));
System.out.println("contains (3, 4): " + points.contains(new Point(3, 4)));
}
}
Point[x=3, y=4] x = 3, y = 4 p.equals(q): true same hashCode: true contains (3, 4): true
record Point(int x, int y) { } the compiler generates -
- a
private finalfield for each component,xandy, - a canonical constructor
Point(int x, int y)which assigns them, - a public accessor method for each component, named after it -
x()andy(), notgetX(), equals()andhashCode()based on all the components, so two records with the same data are equal and work correctly as keys in sets and maps, and- a
toString()which shows the record's name and components,Point[x=3, y=4].
new, passed around and stored like any object. It is implicitly final and extends java.lang.Record.
Validating and normalizing in a compact constructor
//CompactConstructorDemo.java
public class CompactConstructorDemo {
record Student(String name, int marks) {
Student { // compact constructor: no parameter list
if (marks < 0 || marks > 100) {
throw new IllegalArgumentException("marks must be 0-100, got " + marks);
}
name = name.strip(); // normalize before the fields are assigned
}
}
public static void main(String[] args) {
System.out.println(new Student(" Tom ", 78));
try {
new Student("Harry", 120);
} catch (IllegalArgumentException e) {
System.out.println("Rejected: " + e.getMessage());
}
}
}
Student[name=Tom, marks=78] Rejected: marks must be 0-100, got 120
name inside the compact constructor changes the value that is stored in the field, so the record holds "Tom" without the spaces. You can also write the full canonical constructor, or add other constructors which call this(...).
Methods, static members and interfaces
The body of a record can contain methods, static fields, static factory methods and nested types, and a record can implement interfaces -
Adding behaviour to a record
//RecordMethodsDemo.java
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
public class RecordMethodsDemo {
record Money(long cents, String currency) implements Comparable<Money> {
static final Money ZERO_INR = new Money(0, "INR");
static Money of(double amount, String currency) { // static factory method
return new Money(Math.round(amount * 100), currency);
}
Money plus(Money other) { // derived behaviour
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency); // records are immutable: return a new one
}
@Override public String toString() { // override a generated method
return String.format("%s %.2f", currency, cents / 100.0);
}
@Override public int compareTo(Money o) { return Long.compare(cents, o.cents); }
}
public static void main(String[] args) {
List<Money> payments = List.of(Money.of(250.5, "INR"), Money.of(99.99, "INR"), Money.of(1200, "INR"));
Money total = payments.stream().reduce(Money.ZERO_INR, Money::plus);
System.out.println("Total: " + total);
System.out.println("Largest: " + payments.stream().max(Comparator.naturalOrder()).get());
System.out.println("Sorted: " + payments.stream().sorted().collect(Collectors.toList()));
}
}
Total: INR 1550.49 Largest: INR 1200.00 Sorted: [INR 99.99, INR 250.50, INR 1200.00]
plus() return a new record - which makes records safe to share between threads and to use as map keys.
Map.Entry or array -
A local record in a stream pipeline
//LocalRecordDemo.java
import java.util.Comparator;
import java.util.Map;
public class LocalRecordDemo {
public static void main(String[] args) {
Map<String, int[]> marks = Map.of("Tom", new int[] {78, 91}, "Harry", new int[] {85, 64}, "Sam", new int[] {92, 88});
record NameAverage(String name, double average) { } // declared inside the method
marks.entrySet().stream()
.map(e -> new NameAverage(e.getKey(), (e.getValue()[0] + e.getValue()[1]) / 2.0))
.sorted(Comparator.comparingDouble(NameAverage::average).reversed())
.forEach(System.out::println);
}
}
NameAverage[name=Sam, average=90.0] NameAverage[name=Tom, average=84.5] NameAverage[name=Harry, average=74.5]
- A record cannot extend another class (it already extends
Record), and cannot be extended - it is implicitlyfinal. - It cannot declare instance fields other than its components. Static fields are allowed.
- The component fields are
final. Note that this is shallow immutability: a component of typeListcan still be a mutable list - make a defensive copy withList.copyOf()in the compact constructor if needed.
What a record cannot do
//RecordErrors.java
public class RecordErrors {
static class Base { }
record Point(int x, int y) extends Base { }
record Counter(int start) {
private int count;
}
}
RecordErrors.java:4: error: '{' expected
record Point(int x, int y) extends Base { }
^
RecordErrors.java:6: error: field declaration must be static
private int count;
^
(consider replacing field with record component)
2 errors
equals(), hashCode() and toString() generated by the compiler. We validated and normalized data in a compact constructor, added a static factory, derived methods and an interface, and used a local record in a stream pipeline. Lastly, we went through the restrictions of records, with the compiler errors for breaking them. Next, read about pattern matching for instanceofClick to read tutorial: Java 16 Pattern Matching for instanceof.Continue Reading Java 12 to 17 Tutorials
Overview of New Features in Java 16Click to read tutorial: Overview of New Features in Java 16 Java 16 Pattern Matching for instanceofClick to read tutorial: Java 16 Pattern Matching for instanceof Java 16 API Additions: Stream.toList, mapMulti and MoreClick to read tutorial: Java 16 API Additions: Stream.toList, mapMulti and More jpackage Tutorial: Packaging Java Apps (Java 16)Click to read tutorial: jpackage Tutorial: Packaging Java Apps (Java 16) Strong Encapsulation of JDK Internals in Java 16 and 17Click to read tutorial: Strong Encapsulation of JDK Internals in Java 16 and 17 Overview of New Features in Java 12Click to read tutorial: Overview of New Features in Java 12 Overview of New Features in Java 13Click to read tutorial: Overview of New Features in Java 13 Overview of New Features in Java 14Click to read tutorial: Overview of New Features in Java 14 Overview of New Features in Java 15Click to read tutorial: Overview of New Features in Java 15
Overview of New Features in Java 16Click to read tutorial: Overview of New Features in Java 16 Java 16 Pattern Matching for instanceofClick to read tutorial: Java 16 Pattern Matching for instanceof Java 16 API Additions: Stream.toList, mapMulti and MoreClick to read tutorial: Java 16 API Additions: Stream.toList, mapMulti and More jpackage Tutorial: Packaging Java Apps (Java 16)Click to read tutorial: jpackage Tutorial: Packaging Java Apps (Java 16) Strong Encapsulation of JDK Internals in Java 16 and 17Click to read tutorial: Strong Encapsulation of JDK Internals in Java 16 and 17 Overview of New Features in Java 12Click to read tutorial: Overview of New Features in Java 12 Overview of New Features in Java 13Click to read tutorial: Overview of New Features in Java 13 Overview of New Features in Java 14Click to read tutorial: Overview of New Features in Java 14 Overview of New Features in Java 15Click to read tutorial: Overview of New Features in Java 15