Contents

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 …) automatically at the end of the body.

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 You can reassign the component variables inside a compact constructor (e.g., name = name.strip()). The compiler always uses the final value of each variable when it generates the field assignments.

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.

Records generate a public accessor method for each component (e.g., name(), not getName()). You can override these to return a formatted, rounded, or unmodifiable view of the underlying value.

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 } Overriding an accessor changes only what is returned; the stored field value is unchanged. Avoid returning a value that is inconsistent with what was stored, as it breaks the transparency contract of records.

Records are immutable — there are no setters. The conventional way to produce a modified copy is to write wither methods (named with<Component>) that return a new record with one component changed.

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();

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 For mutable collection components prefer List.copyOf(), Set.copyOf(), or Map.copyOf() in the compact constructor — they produce an unmodifiable snapshot so you don't need to override the accessor too.

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