Available since JDK 16, a record is a special kind of class that defines an aggregate of values.
A record, defined by the context-sensitive keyword record, provides an efficient and easy-to-use way to hold a group of values, reason why it’s also referred to as an aggregate type. For example, you can use a record to hold customer data or car attributes. More than aggregate values, records have some of the capabilities of a class, and features that simplify its declaration and access to its values.
We can enumerate two motivations for having a record:
- It reduces the effort to encapsulate values into a single unit (although you can also accomplish that with a class, using a record can free you from writing constructors, getter methods, and overriding methods inherited by Object);
- It indicates that the purpose of a class is to hold a group of data, rather than be a full-featured class.
Record declaration and instantiation
As stated, the keyword record is context-sensitive, which means that the Java compiler can identify when it’s being used to declare a record, and you can still use the word record in other contexts. This is a basic record declaration.
record recordName(component-list) {
// optional body
}
The data that the record is going to hold is determined by the comma-separated list of parameters declaration called component list that follows the record name. The body is optional because the compiler will automatically construct the record, create the getter methods, and override toString(), equals(), and hashCode() — methods inherited from Object.
Let’s see an example of a record declaration:
record Client(int id, String name) {}
In the example above, Client is the name of the record and it has two components: the integer id and the string name.
With this declaration, a number of elements are going to be automatically created by the compiler:
- Private final fields for id and name;
- Public getter methods that have the same name and return types as the record components
- A canonical constructor, which is a constructor with a parameter list that contains the same elements, and in the same order, as the component list.
A record can be instantiated the same way you would instantiate a class:
Client client = new Client(1, "John Doe");
You can access the fields of the instance of a record with the getter methods, which have the same name of the fields:
System.out.println("The client ID is " + client.id() + " and his/her name is " +
client.name());
The resulting output is:
The client ID is 1 and his/her name is John Doe
Since every field in a record is private final, the data held by a record is immutable. More precisely, records are said to be shallowly immutable because if a field of a record references an object, you can still make a change to that object, but you can’t change to what object it refers.
Some considerations about records:
- Records implicitly inherits java.lang.Record, which specifies abstract overrides of the equals( ), hashCode( ), and toString( ): implicit implementations of these methods are automatically created. Therefore, a record can’t inherit another class (Java doesn’t allow multiple inheritance), although it can implement one or more interfaces;
- All records are final, therefore they cannot be extended;
- Except from equals, you can’t use the names of method defined by Object to name you record’s components;
- Records can be generic;
- Instance field is not allowed in record, therefore any other fields aside from the ones associated with a record’s components must be static, i.e., the fields declared in the body of the record.
Records as elements in a list
You can use instances of records as elements in a list:
record Client(int id, String name) {}
public class RecordDemo {
public static void main(String[] args) {
Client[] clientList = new Client[3];
clientList[0] = new Client(123, "John Doe");
clientList[1] = new Client(124, "Martha Smith");
clientList[2] = new Client(125, "Henry Jones");
for (Client client : clientList) {
System.out.println("The client ID is " + client.id() + " and his/her name is " + client.name());
}
}
}
Declaring record constructors
Most of the times the canonical constructor implemented by the compiler is going to fit your needs. However, you can create one or more of your own constructors; and you can also define an implementation of the canonical constructor. For a record, there are two types of constructors that you can explicitly declare: canonical and non-canonical, and there are some reasons why you’ll want to do so:
- Verify the format of a value;
- Perform validations;
- Check for nulls.
Declaring a canonical constructor
Although the canonical constructor is provided by the compiler, there are two ways that you can code your own implementation: the full form and using the compact record constructor.
You can declare the full form of the canonical constructor the same way you would with any other constructor:
record Client(int id, String name) {
public Client(int id, String name) {
this.id = id;
this.name = name.trim();
}
}
In the example above, we are using the constructor to remove any leading or trailing whitespace from the name. A few important things to notice about the canonical constructor:
- Canonical constructor parameter names must match record component names;
- Each component must be fully initialized upon completion of the constructor;
- It can’t be generic;
- It can’t include a throws clause;
- It can’t invoke another constructor;
- It must be at least as accessible as its record declaration.
Now let’s see the compact constructor. You can declare a compact record constructor by specifying the name of the record without parameters. The parameters are going to be implicitly added, and its components are automatically assigned the values of arguments passed to the constructor, but you can still change one or more of the arguments. Here’s an example:
record Client(int id, String name) {
public Client {
name = name.trim();
}
}
Here, trim( ) is called on the name parameter (which is implicitly declared by the compact constructor) and the result is assigned back to the name parameter. Both parameters id and name are implicitly assigned to their corresponding fields when the constructor ends, so there is no need to initialize the fields explicitly (in fact, it’s illegal to do so). That’s why we don’t use the this keyword here, differently from the full form constructor.
Declaring a non-canonical constructor
A non-canonical constructor is declared the same way as any other constructor, but it must first call another constructor via this, often a canonical constructor:
record Client(int id, String name) {
static int noId = -1;
public Client {
name = name.trim();
}
public Client(String name) {
this(noId, name);
}
}
public class RecordDemo {
public static void main(String[] args) {
Client[] clientList = new Client[3];
clientList[0] = new Client(123, "John Doe");
clientList[1] = new Client(124, "Martha Smith");
clientList[2] = new Client("Henry Jones");
for (Client client : clientList) {
System.out.println("The client ID is " + client.id() + " and his/her name is " + client.name());
}
}
}
In the example above, we are using a non-canonical constructor to give id a default placeholder value. Notice that, as said before, a record cannot have instance fields, but it can have a static one.
A record can have as many constructors as its needs.
Conclusion
Java records offer a streamlined and efficient way to encapsulate data, making them ideal for situations where the primary purpose of a class is to hold a group of values. By automatically generating constructors, getter methods, and overrides for toString(), equals(), and hashCode(), records reduce boilerplate code and ensure immutability. While they share some features with classes, such as the ability to implement interfaces and define constructors, records are final and cannot be extended. This makes them a powerful tool for simplifying data structures in Java.