Enums, Records & Sealed Types

Record Components & Methods

15 min Lesson 5 of 13

Record Components & Methods

In the previous lesson you saw how to declare a record and why it generates so much boilerplate for you. Now we go one level deeper: how exactly does that generated code work, how can you customise the constructors, and how do you add validation and extra behaviour without losing the compactness that makes records valuable?

What a Record Generates Automatically

Every record component — the variables declared in the header — becomes a private final field. For each component the compiler also generates a public accessor method whose name is exactly the component name (no get prefix). Consider:

record Point(double x, double y) {}

You get for free:

  • public double x() — returns the x field.
  • public double y() — returns the y field.
  • A canonical constructor Point(double x, double y) that sets both fields.
  • equals(), hashCode(), and toString() based on the components.
Accessor naming differs from JavaBeans. Traditional getter methods are named getName(). Record accessors are named name(). If you call point.getX() on a record you will get a compile error — the method is point.x().

The Canonical Constructor

The canonical constructor is the one that takes every component as a parameter in declaration order. By default it is fully generated. You can explicitly declare it to add logic — most commonly validation:

record Range(int min, int max) { Range(int min, int max) { // explicit canonical constructor if (min > max) { throw new IllegalArgumentException( "min (" + min + ") must not exceed max (" + max + ")"); } this.min = min; this.max = max; } }

Now every way to create a Range goes through this constructor, so the invariant is always enforced:

Range valid = new Range(1, 10); // ok Range invalid = new Range(10, 1); // throws IllegalArgumentException

The Compact Constructor — Cleaner Validation

Explicitly writing the canonical constructor is verbose because you must repeat the parameter list and assign every field. Java records offer a shorter form called the compact constructor: you omit the parameter list entirely, and the compiler automatically assigns the (possibly modified) parameters to the fields at the end.

record Range(int min, int max) { Range { // compact constructor — no parameter list if (min > max) { throw new IllegalArgumentException( "min (" + min + ") must not exceed max (" + max + ")"); } // assignment this.min = min; this.max = max; is done automatically } }

Inside the compact constructor you can also reassign the parameter variables before they are stored:

record NormalizedEmail(String address) { NormalizedEmail { if (address == null || address.isBlank()) { throw new IllegalArgumentException("Email must not be blank"); } address = address.strip().toLowerCase(); // reassign, not this.address } }
Prefer the compact constructor for validation and normalisation. It is shorter, less error-prone (no risk of forgetting an assignment), and expresses intent clearly. Use the explicit canonical constructor only when you need fine-grained control over what is stored.

Adding Instance Methods

Records can have any number of regular instance methods. They cannot add mutable state (no extra non-static fields), but they can compute derived values from the components:

record Circle(double radius) { Circle { if (radius <= 0) throw new IllegalArgumentException("Radius must be positive"); } double area() { return Math.PI * radius * radius; } double circumference() { return 2 * Math.PI * radius; } boolean contains(double x, double y) { return (x * x + y * y) <= (radius * radius); } }

Using it feels natural:

Circle c = new Circle(5.0); System.out.println(c.radius()); // 5.0 — generated accessor System.out.println(c.area()); // 78.539... System.out.println(c.contains(3, 4)); // true (3² + 4² = 25 = 5²)

Static Members

Records may also have static fields and factory methods, which is the idiomatic way to provide named constructors or constants:

record Money(long cents, String currency) { static final Money ZERO_USD = new Money(0, "USD"); static Money ofDollars(double dollars) { return new Money(Math.round(dollars * 100), "USD"); } Money add(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Cannot add different currencies"); } return new Money(cents + other.cents, currency); } @Override public String toString() { return String.format("%s %.2f", currency, cents / 100.0); } }
Money price = Money.ofDollars(9.99); Money shipping = Money.ofDollars(4.95); Money total = price.add(shipping); System.out.println(total); // USD 14.94
You cannot override the generated accessors to change their return type. You can override an accessor to add logic (e.g., a defensive copy), but the parameter type and return type must match the component type exactly, or the code will not compile.

Overriding equals, hashCode, and toString

The generated equals() checks all components with Objects.equals(), and hashCode() uses all components too. This is correct for most records. Override them only when you need custom semantics — for example, case-insensitive equality for a string-backed record:

record CaseInsensitiveKey(String value) { @Override public boolean equals(Object o) { return o instanceof CaseInsensitiveKey(String v) && value.equalsIgnoreCase(v); } @Override public int hashCode() { return value.toLowerCase().hashCode(); } }

Summary

Records generate typed accessors (named after the component, no get prefix), a canonical constructor, and value-based equals/hashCode/toString. Use the compact constructor to validate or normalise inputs in a concise way. Add instance methods for derived behaviour and static factory methods for convenient creation. These tools let you build expressive, safe value types with very little code.