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 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)));
  }
}
 OUTPUT of the above code
Point[x=3, y=4]
x = 3, y = 4
p.equals(q): true
same hashCode: true
contains (3, 4): true
From record Point(int x, int y) { } the compiler generates -
  • a private final field for each component, x and y,
  • a canonical constructor Point(int x, int y) which assigns them,
  • a public accessor method for each component, named after it - x() and y(), not getX(),
  • equals() and hashCode() 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].
A record is a class: it can be created with new, passed around and stored like any object. It is implicitly final and extends java.lang.Record.
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 -
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());
    }
  }
}
 OUTPUT of the above code
Student[name=Tom, marks=78]
Rejected: marks must be 0-100, got 120
Assigning to the parameter 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()));
  }
}
 OUTPUT of the above code
Total: INR 1550.49
Largest: INR 1200.00
Sorted: [INR 99.99, INR 250.50, INR 1200.00]
Since a record's fields are final, "modifying" methods like plus() return a new record - which makes records safe to share between threads and to use as map keys.
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 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);
  }
}
 OUTPUT of the above code
NameAverage[name=Sam, average=90.0]
NameAverage[name=Tom, average=84.5]
NameAverage[name=Harry, average=74.5]
Restrictions Records are deliberately limited, so that a record is always just its data -
  • A record cannot extend another class (it already extends Record), and cannot be extended - it is implicitly final.
  • 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 type List can still be a mutable list - make a defensive copy with List.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;
  }
}
 OUTPUT on compiling the above code
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
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() 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.