Java Record Fields: Syntax and Behavior
java record fields: Understand Java record fields: how they are declared, accessed, validated, and restricted, with practical examples and edge cases.
When you declare a Java record, the components you list in the header are not the same as the fields that the compiler generates. Understanding the distinction between record components and record fields is essential for writing correct code, especially when you add validation or serialization. This article explains how java record fields work, how they are generated, and where you can and cannot customize them.
The Difference Between Record Components and Fields
A record declaration like record Point(int x, int y) {} introduces two record components: x and y. The compiler then generates private final fields with the same names, a canonical constructor, accessor methods, equals, hashCode, and toString. The generated fields are what actually hold the data. The record components are just the declaration-level description that drives the generated code.
This distinction matters because you cannot refer to the fields directly in the record body. If you write this.x inside a method, it works, but that is actually accessing the generated field through the implicit reference. The compiler treats the component names as field names for convenience, but the fields themselves are not declared by you.
How Record Fields Are Declared and Initialized
Record fields are always private final. The canonical constructor assigns each parameter to the corresponding field. You do not write this assignment yourself; the compiler does it. For example:
public record Rectangle(double width, double height) {}
The generated constructor is equivalent to:
public Rectangle(double width, double height) { this.width = width; this.height = height; }
Because the fields are final, they must be assigned exactly once. You cannot reassign them later. This is the foundation of record immutability. If you need a derived value, you can compute it in a compact constructor and assign it to a different field, but you cannot change the original component values after construction.
Accessor Methods and Field Visibility
The compiler generates accessor methods that match the component names. For Rectangle, you get width() and height(). These methods return the field values. The fields themselves are private, so the only way to read them from outside the record is through the accessors.
Rectangle r = new Rectangle(3.0, 4.0); System.out.println(r.width()); // 3.0
You can override an accessor in the record body, but the override must still return the field value or a computed value based on it. Overriding an accessor does not change the field; it changes the method that exposes it. This can be useful for defensive copies or lazy computation, but it does not alter the underlying data.
Custom Constructors and Field Validation
Records allow you to define a compact constructor to validate or normalize the components before they are assigned to fields. The compact constructor syntax omits parameters and assignments; you only write the validation logic. The compiler inserts the field assignments after your code.
public record Temperature(double celsius) { public Temperature { if (celsius < -273.15) { throw new IllegalArgumentException("Temperature below absolute zero"); } } }
Here, the validation runs before the field celsius is set. If the check fails, the object is never created, so the fields are never assigned. This is the primary way to enforce invariants on java record fields. You can also normalize the value by reassigning the parameter inside the compact constructor, but you cannot reassign the field directly.
If you need a different constructor signature, you can define an additional constructor that delegates to the canonical one. The delegation must call this(...) as the first statement. This allows you to provide defaults or convert types while keeping the field initialization consistent.
Restrictions on Record Fields
Record fields are implicitly static and final only if you declare them as such. However, you cannot add instance fields to a record. The only instance fields are the ones generated from the components. You can add static fields, static methods, and static initializers, but any attempt to declare an instance field in the record body is a compile error.
public record Person(String name) { private static final int MAX_NAME_LENGTH = 100; // allowed // private int age; // compile error }
This restriction is intentional. Records are designed to be transparent carriers of data. Allowing additional instance fields would break the contract that the state is exactly the component set. If you need extra state, you should use a class instead of a record.
Serialization and Reflection with Record Fields
Records interact with serialization differently than normal classes. When a record is serialized, the serialized form is based on the components, not the fields. During deserialization, the canonical constructor is invoked with the serialized component values. This means that validation in the compact constructor runs again, which is generally desirable.
Reflection on record fields also behaves specially. The Class.getDeclaredFields() method returns the generated fields, but you can use getRecordComponents() to inspect the components in a more semantic way. The fields are private, but reflection can access them. However, because they are final, modifying them via reflection is unreliable and discouraged. The Record class provides no special methods to change field values; immutability is expected.
Performance and Memory Characteristics of Record Fields
Record fields are stored directly in the object, just like fields in a normal class. There is no additional indirection or wrapper. Because they are final, the JVM can sometimes optimize access more aggressively, but the practical performance difference compared to a hand-written immutable class is negligible. The main cost is the same: one field per component.
One subtle point is that records do not support lazy initialization of fields. If you need a cached derived value, you cannot add a non-static field to hold it. You could compute it in an accessor each time, but that repeats work. Alternatively, you can use a static Map keyed by the record instance, but that introduces memory overhead and concurrency concerns. For most use cases, the simplicity of record fields outweighs the need for caching.
Working with Record Fields in Collections and Streams
Because record fields are exposed via accessor methods, you can use method references directly in streams. For example, sorting a list of records by a field is straightforward:
List<Person> people = List.of(new Person("Alice", 30), new Person("Bob", 25)); people.stream() .sorted(Comparator.comparing(Person::age)) .forEach(p -> System.out.println(p.name()));
The accessor methods are the only way to reach the fields, which keeps the API clean and consistent. This pattern works well with functional programming idioms and does not require any special handling.
Common Pitfalls with Record Fields
One common mistake is assuming that record fields are mutable if the component type is mutable. For example, record Bag(List<String> items) creates a final field items that references a List. The reference cannot be changed, but the list itself can be modified. This breaks the immutability expectation. If you need true immutability, you must defensively copy the component in the compact constructor or use an immutable collection type.
Another pitfall is overriding equals and hashCode incorrectly. Records generate these methods based on the fields, but if you override them, you must ensure they remain consistent with the field values. The generated implementations are correct for most cases, so overriding them is rarely necessary.
Finally, remember that record fields are not accessible from subclasses because records are final. You cannot extend a record. If you need inheritance, you must use a class. This is a deliberate design choice to keep records simple and unambiguous.
When to Choose a Record Instead of a Class
Use a record when your primary goal is to carry immutable data with minimal ceremony. If you need additional instance fields, mutable state, or inheritance, a class is the right choice. Records shine in data transfer objects, value objects, and message payloads. They also work well as keys in maps because of the value-based equals and hashCode.
The decision often comes down to whether the fields are the complete state. If the answer is yes, a record is appropriate. If you need to hide some fields or add derived state, a class gives you more control. In practice, java record fields cover a large portion of the data-holder use cases, and the restrictions they impose often lead to cleaner designs.