JAVA / RECORDS, SEALED TYPES AND ENUMS
Records and their compact canonical form
Declare records and use the compact canonical constructor to validate, normalize and defensively copy component values before the compiler stores them.
What you will learn
- Write a compact canonical constructor: the record name, a body, no parameter list.
- Validate and throw inside it so an invalid record instance never comes to exist.
- Normalize by reassigning the parameters, never by assigning to this.field.
- Know when the long canonical form is needed and how extra constructors delegate.
Understanding Records and their compact canonical form
A record header like record User(String name, int age) is a complete description of the type's state. From it the compiler derives one private final field per component, an accessor named exactly after the component (name(), not getName()), a constructor whose parameters match the header in type and order - the canonical constructor - and equals, hashCode and toString built from every component. Nothing else can hold instance state, because a record may not declare additional instance fields, so the header is not shorthand for a class body, it is the identity of the type.
The canonical constructor is the only code that writes those fields, and you can take it over in two ways. The long form repeats the parameter list and you assign this.name = name; for each component yourself. The compact form is the record name followed directly by a block - no parentheses, no parameter list - where the parameters exist implicitly with the component names and types, and the compiler appends this.name = name; this.age = age; after your last statement.
That ordering is the entire mental model: your body runs on the incoming parameters, and whatever those parameters hold at the end is frozen into the fields. So name = name.strip() changes what the object stores, a throw prevents the object from existing at all, and this.name read inside a compact body still gives the field's default value because the assignment has not run yet. Assigning to this.name in the compact form is a compile error, since those assignments are generated code you are not allowed to duplicate.
public class Main {
record User(String name, int age) {
User {
if (age < 0) {
throw new IllegalArgumentException("age must be >= 0, got " + age);
}
name = name.strip().toLowerCase();
}
}
public static void main(String[] args) {
User u = new User(" ADA Lovelace ", 36);
System.out.println(u);
System.out.println("[" + u.name() + "]");
System.out.println(u.equals(new User("Ada LOVELACE", 36)));
try {
new User("bob", -1);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}A compact canonical constructor runs before the compiler's implicit field assignments, so reassigning its parameters is how you decide what the record actually stores.
Worked examples
Long form and a delegating constructor
The same record written with the full canonical constructor, plus a second constructor of a different signature.
public class Main {
// The long form: parameter list spelled out, assignments written by hand.
record Range(int lo, int hi) {
Range(int lo, int hi) {
if (lo > hi) {
int tmp = lo;
lo = hi;
hi = tmp;
}
this.lo = lo;
this.hi = hi;
}
Range(int hi) {
this(0, hi);
}
int length() {
return hi - lo;
}
}
public static void main(String[] args) {
System.out.println(new Range(9, 2));
System.out.println(new Range(5));
System.out.println(new Range(9, 2).length());
}
}Example explained
Line 1Range(int lo, int hi) is canonical because its parameter types match the header in the same order.
Line 2Since the parameter list is written out, this.lo = lo; and this.hi = hi; are mandatory - nothing is appended for you.
Line 3The compact equivalent is Range { if (lo > hi) { ... } } with both this. lines deleted.
Line 4Range(int hi) has a different signature, so it is a secondary constructor and must start with this(0, hi).
Defensive copy of a mutable component
Reassigning the parameter to an unmodifiable copy makes the record independent of the caller's list.
import java.util.ArrayList;
import java.util.List;
public class Main {
record Playlist(String title, List<String> tracks) {
Playlist {
tracks = List.copyOf(tracks);
}
}
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("Blue in Green"));
Playlist p = new Playlist("Kind of Blue", source);
source.add("So What");
System.out.println(p.tracks());
System.out.println(source.size());
try {
p.tracks().add("Flamenco Sketches");
} catch (UnsupportedOperationException e) {
System.out.println("the stored list is a copy");
}
}
}Example explained
Line 1tracks = List.copyOf(tracks) reassigns the parameter, and the generated assignment at the end of the body puts that copy into the field.
Line 2source.add("So What") therefore cannot reach the record: source has two elements while the record still reports one.
Line 3The accessor hands back the stored copy, so add on it throws UnsupportedOperationException rather than mutating the record.
Line 4Without the copy line the record would hold a live reference to source and its toString would change over time.
Important notes
You may supply the compact form or the full canonical constructor, never both, and a compact constructor cannot begin with this(...); only constructors with a different signature delegate.
For a public record an explicit canonical constructor must also be declared public, while the implicitly generated one simply takes the record's own access level.
Common mistakes
Writing this.name = name; inside a compact constructor: it does not compile, because that assignment is generated for you - assign to the parameter name instead.
Keeping the parentheses, as in User(String name, int age) { if (age < 0) throw ...; }, but dropping the this. assignments: that is the long form, and javac rejects it because the component fields are never initialized.
Validating with this.age or age() inside a compact body: both read the field before the implicit assignment, so you test 0 or null and the genuinely bad argument is accepted.
Try it yourself
Change, predict, then run
Declare record Rgb(int r, int g, int b) with a compact constructor that clamps each channel into 0..255 by reassigning the parameters, then print new Rgb(300, -5, 128) and whether it equals new Rgb(255, 0, 128).
Open the Java workspaceCheck your understanding
Inside a compact canonical constructor for record Money(String currency, long cents), the line currency = currency.toUpperCase(); changes what money.currency() returns later. Why?
- The accessor currency() re-runs the constructor body on each call.
- Your body runs first and the compiler appends this.currency = currency;, so the field receives whatever the parameter holds at the end.
- Assignments to a record constructor's parameters are automatically redirected to the matching field.
- Records normalize their components lazily, applying the transformation each time an accessor is used.
Show answer
The compact body executes before the generated assignments, so the parameter is an ordinary mutable local whose final value is copied into the field exactly once. Option 3 is tempting because the effect looks like redirection, but nothing is rewritten: currency is just a local variable, which is precisely why this.currency = currency; is illegal there and why reading this.currency earlier in the body still yields null.