Enums, Records & Sealed Types

Records: Customization & Limits

15 min Lesson 6 of 13

Records: Customization & Limits

The previous two lessons introduced the record syntax and the automatically generated components. You know that record Point(int x, int y) gives you a constructor, accessors, equals, hashCode, and toString for free. This lesson goes one level deeper: what can you add to a record yourself, and where does the JVM draw a hard line?

Adding Instance Methods

A record is a fully fledged class. You can add as many instance methods as you like — they are read-only views or computations over the component values.

public record Money(long cents, String currency) { // Computed property — no extra field needed public double inMajorUnit() { return cents / 100.0; } // Predicate helper public boolean isZero() { return cents == 0; } // Arithmetic that returns a NEW Money (records are immutable) public Money add(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Currency mismatch"); } return new Money(cents + other.cents, currency); } }

Notice that add does not modify this — it returns a brand-new instance. That is the idiomatic pattern for immutable value types.

Static Factory Methods

Constructors are inflexible: you call them with raw values and that is it. Static factory methods are a classic Java pattern that buys you named constructors, validation, caching, and descriptive intent.

public record Money(long cents, String currency) { // Compact constructor — validation before the fields are assigned public Money { if (cents < 0) throw new IllegalArgumentException("Negative amount"); if (currency == null || currency.isBlank()) throw new IllegalArgumentException("Currency required"); currency = currency.toUpperCase(); // normalise inside compact constructor } // Static factory: build from a decimal string public static Money of(String amount, String currency) { // "9.99" -> 999 cents long c = Math.round(Double.parseDouble(amount) * 100); return new Money(c, currency); } // Named zero constant via factory public static Money zero(String currency) { return new Money(0, currency); } }
Prefer static factories over extra constructors. Records support only one canonical constructor (or its compact form). Static factories let you express intent in the name — Money.of("9.99", "USD") reads far better than new Money(999L, "USD").

Static Fields and Utility Constants

Records can have static fields. The prohibition on extra fields is limited to instance fields, because an extra instance field would break the guarantee that a record's value is fully described by its components.

public record Coordinates(double lat, double lon) { // Static constant — fine private static final double EARTH_RADIUS_KM = 6371.0; // Static utility on the type public static Coordinates origin() { return new Coordinates(0.0, 0.0); } public double distanceTo(Coordinates other) { // Haversine formula (simplified) double dLat = Math.toRadians(other.lat - lat); double dLon = Math.toRadians(other.lon - lon); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat)) * Math.cos(Math.toRadians(other.lat)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } }

Implementing Interfaces

Records can implement interfaces — this is a crucial extensibility hook. You often combine records with Comparable, custom marker interfaces, or sealed interface hierarchies.

public record Money(long cents, String currency) implements Comparable<Money> { @Override public int compareTo(Money other) { if (!currency.equals(other.currency)) throw new IllegalArgumentException("Cannot compare different currencies"); return Long.compare(cents, other.cents); } } // Usage List<Money> prices = List.of( new Money(1500, "USD"), new Money(200, "USD"), new Money(900, "USD") ); List<Money> sorted = prices.stream().sorted().toList(); // sorted: [Money[cents=200, currency=USD], Money[cents=900, ...], Money[cents=1500, ...]]

What Records Cannot Do

The immutability guarantee comes with firm restrictions:

  • No additional instance fields. Every piece of state must be declared as a component in the header. This is intentional — the record's identity is its components.
  • Records cannot extend a class. They implicitly extend java.lang.Record and cannot extend anything else. This is because the JVM must be able to guarantee the component contract.
  • Records cannot be abstract. An abstract record would undermine the sealed, immutable value guarantee.
  • Component accessors cannot be removed. You can override them (e.g. to add defensive copying for mutable component types), but you cannot hide or remove them.
  • You cannot make a record mutable. Component fields are always private final. Any attempt to reassign them inside the class is a compile error.
Mutable components are a trap. If a record holds a mutable object — say, record Snapshot(List<String> items) — the reference is final but the list itself is not. Callers can still mutate snapshot.items().add("oops"). Fix it by making a defensive copy in the compact constructor: items = List.copyOf(items);
Records versus JavaBeans. A common interview question is why records do not follow the getX() accessor convention. Records use plain x() because they model data carriers, not mutable beans. Frameworks that require JavaBean-style getters (older Jackson, some JPA providers) may need explicit configuration or a custom serialiser.

Overriding the Generated Methods

All four generated methods — the canonical constructor, equals, hashCode, and toString — can be overridden:

public record Temperature(double value, String unit) { // Override toString for a friendlier format @Override public String toString() { return String.format("%.1f %s", value, unit); } // Override equals to allow cross-unit comparison (simplified) @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Temperature other)) return false; double thisC = toCelsius(); double otherC = other.toCelsius(); return Math.abs(thisC - otherC) < 0.001; } @Override public int hashCode() { return Double.hashCode(Math.round(toCelsius() * 1000) / 1000.0); } private double toCelsius() { return switch (unit) { case "C" -> value; case "F" -> (value - 32) * 5.0 / 9.0; case "K" -> value - 273.15; default -> throw new IllegalArgumentException("Unknown unit: " + unit); }; } }

Summary

Records are concise but not rigid. You can enrich them with instance methods, static factories, static constants, and interface implementations. The hard limits — no extra instance fields, no class inheritance, no mutability — are features, not bugs: they guarantee that a record's value is always fully and transparently described by the components you declared in its header. When you need more flexibility than records allow, a regular immutable class (with private finals and no setters) is the right tool.