Java Record: Compact Constructors and Custom Accessors (Java 16+)
Java Records auto-generate a canonical constructor and accessor methods from their component list. But you can go further: compact constructors let you add validation and normalization without repeating assignments; custom accessors can transform or defensively copy the returned value; and the withers pattern gives you a clean idiom for producing modified copies of immutable records.
Contents
Compact Constructors
A compact constructor has no parameter list: the compiler supplies them from the record components. Inside the body you can validate or normalize the incoming values; the compiler inserts the field assignments (this.x = x
record Range(int min, int max) {
// Compact constructor — just the body, no parameters
Range {
if (min > max)
throw new IllegalArgumentException(
"min (%d) must be <= max (%d)".formatted(min, max));
// Compiler inserts: this.min = min; this.max = max;
}
}
Range r = new Range(1, 10); // ok
Range bad = new Range(5, 2); // throws IllegalArgumentException
record Person(String name, int age) {
Person {
Objects.requireNonNull(name, "name must not be null");
name = name.strip(); // normalize before assignment
age = Math.max(0, age); // clamp negative values
}
}
Person p = new Person(" Alice ", -1);
System.out.println(p.name()); // "Alice"
System.out.println(p.age()); // 0
name = name.strip()Explicit Canonical Constructor
For situations that need more control you can write an explicit canonical constructor instead. You must assign every field yourself.
record Coordinate(double lat, double lon) {
// Explicit canonical constructor — you handle every assignment
Coordinate(double lat, double lon) {
if (lat < -90 || lat > 90) throw new IllegalArgumentException("lat out of range");
if (lon < -180 || lon > 180) throw new IllegalArgumentException("lon out of range");
this.lat = lat;
this.lon = lon;
}
}
The compact form is preferred for simple validation; the explicit form is useful when you need to compute derived values or call helper methods before assignment.
Overriding Auto-generated Accessors
Records generate a public accessor method for each component (e.g., name()getName()
record Product(String name, double price) {
// Override the accessor to round to 2 decimal places on read
@Override
public double price() {
return Math.round(price * 100.0) / 100.0;
}
}
Product p = new Product("Widget", 9.999);
System.out.println(p.price()); // 10.0
import java.util.List;
import java.util.Collections;
record Playlist(String title, List<String> tracks) {
// Compact constructor — store a defensive copy
Playlist {
tracks = List.copyOf(tracks); // immutable defensive copy
}
// Accessor is fine — List.copyOf already returns an unmodifiable list
}
The Withers Pattern: Immutable Updates
Records are immutable: there are no setters. The conventional way to produce a modified copy is to write wither methods (named with<Component>
record User(long id, String name, String email, boolean active) {
public User withName(String newName) {
return new User(id, newName, email, active);
}
public User withEmail(String newEmail) {
return new User(id, name, newEmail, active);
}
public User withActive(boolean newActive) {
return new User(id, name, email, newActive);
}
}
User original = new User(1, "Alice", "alice@example.com", true);
User renamed = original.withName("Alicia");
User disabled = renamed.withActive(false);
System.out.println(original); // User[id=1, name=Alice, email=alice@example.com, active=true]
System.out.println(disabled); // User[id=1, name=Alicia, email=alice@example.com, active=false]
For records with many components you can reduce wither boilerplate with a builder-style approach:
record Config(String host, int port, boolean tls, int timeoutMs) {
// A small builder for making multiple changes at once
public Builder toBuilder() { return new Builder(this); }
public static class Builder {
private String host;
private int port;
private boolean tls;
private int timeoutMs;
Builder(Config c) {
this.host = c.host;
this.port = c.port;
this.tls = c.tls;
this.timeoutMs = c.timeoutMs;
}
public Builder host(String h) { this.host = h; return this; }
public Builder port(int p) { this.port = p; return this; }
public Builder tls(boolean t) { this.tls = t; return this; }
public Builder timeoutMs(int t) { this.timeoutMs = t; return this; }
public Config build() { return new Config(host, port, tls, timeoutMs); }
}
}
Config defaults = new Config("localhost", 8080, false, 5000);
Config production = defaults.toBuilder()
.host("prod.example.com")
.port(443)
.tls(true)
.build();
Defensive Copies
Record components are final references but the objects they point to may be mutable. Without a defensive copy, callers can mutate shared state after construction.
import java.time.LocalDate;
import java.util.Arrays;
// UNSAFE — caller can mutate the array after construction
record BadSnapshot(int[] values) {}
// SAFE — store and return copies
record Snapshot(int[] values) {
// Compact constructor: defensive copy on the way IN
Snapshot {
values = values.clone();
}
// Override accessor: defensive copy on the way OUT
@Override
public int[] values() {
return values.clone();
}
}
int[] data = {1, 2, 3};
Snapshot s = new Snapshot(data);
data[0] = 99; // modifying original
System.out.println(s.values()[0]); // 1 — snapshot is unchanged
int[] retrieved = s.values();
retrieved[0] = 77;
System.out.println(s.values()[0]); // 1 — retrieved copy cannot affect record
List.copyOf()Set.copyOf()Map.copyOf()Records Implementing Interfaces
Records can implement interfaces (but cannot extend classes). This makes them ideal for modelling variant types in combination with sealed interfaces.
interface Expr {
int eval();
}
record Num(int value) implements Expr {
public int eval() { return value; }
}
record Add(Expr left, Expr right) implements Expr {
public int eval() { return left.eval() + right.eval(); }
}
record Mul(Expr left, Expr right) implements Expr {
public int eval() { return left.eval() * right.eval(); }
}
// (2 + 3) * 4
Expr expr = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
System.out.println(expr.eval()); // 20
// Records work with Comparable and standard library interfaces
record SemVer(int major, int minor, int patch)
implements Comparable<SemVer> {
// Compact constructor: reject negative values
SemVer {
if (major < 0 || minor < 0 || patch < 0)
throw new IllegalArgumentException("Version components must be non-negative");
}
@Override
public int compareTo(SemVer o) {
int c = Integer.compare(this.major, o.major);
if (c != 0) return c;
c = Integer.compare(this.minor, o.minor);
return c != 0 ? c : Integer.compare(this.patch, o.patch);
}
@Override
public String toString() {
return major + "." + minor + "." + patch;
}
}
var versions = List.of(
new SemVer(2, 1, 0),
new SemVer(1, 9, 3),
new SemVer(2, 0, 1)
);
versions.stream().sorted().forEach(System.out::println);
// 1.9.3 → 2.0.1 → 2.1.0