All you need to know about Java Record Classes
|Java record class is the new language construct introduced into java language to provide succinct way to create types that carry immutable data. record classes are a special kind of java classes with some restrictions. record classes are less verbose than the regular classes.
Defining a record involves, name of the type, its components and body. In the following example countryCode and cityCode are the components of the City record. The component list is called record header.
<class-modifier> record RecordIdentifier RecordHeader {} public record City(String countryCode, String cityCode) {}
The design goal of the record classes is to provide a transparent data carriers, verbosity is reduced because record class automatically adds all the required boilerplate. Every record class also extends java.lang.Record implicitly.
Every record class adds the following class members automatically based on the record header
- private final field for every component (with the same name as that of the component name)
- public accessor method – City::name(), City::population()
- implementation for equals (two records are equal if the type is same and all the components have the same type), hashCode and toString methods.
- Default constructor with all the components in the same order as that of the record header.
public final class City extends java.lang.Record { private final java.lang.String countryCode; private final java.lang.String cityCode; public City(java.lang.String, java.lang.String); public final java.lang.String toString(); public final int hashCode(); public final boolean equals(java.lang.Object); public java.lang.String countryCode(); public java.lang.String cityCode(); }
Similar to regular classes, record instances are created using new keyword.
var city = new City("in","ch"); System.out.println("Chennai: "+city); // Chennai City[countryCode=in, cityCode=ch]
Restrictions on record classes
Record classes can’t be abstract and no other class can extend them (they are implicitly final).
Record class can’t extend any class (interface implementation is allowed). All record classes implicitly extend java.lang.Record class.
To confine the state of the record (only record header should be able to define the state of the record), no explicit declaration of instance fields or instance initializers are allowed (static fields and static initializers are allowed). Native methods are also not allowed in records.
apart from these restrictions record classes behave like regular classes. So annotations, static fields, static methods, static initializers, interface implementation, generics and serialisation all work as expected.
Compact constructor
record classes allow overriding of the automatically defined constructor and accessor methods. But the implementation should match type and semantics of the auto generated methods.
public record City(String countryCode, String cityCode) { // overriding canonical constructor public City(String countryCode, String cityCode) { Objects.requireNonNull(cityCode); Objects.requireNonNull(cityCode); this.countryCode = countryCode.toLowerCase(); this.cityCode = cityCode.toLowerCase(); } // overriding accessor public String countryCode() { return countryCode.isBlank() ? "--" : countryCode; } // unrelated instance method public String geoCode() { return countryCode() + ":" + cityCode(); } }
To reduce verbosity, Record classes provides a constructor called compact contractor. Compact constructor takes no arguments but all the components will be available in the constructor body. Assigning components to their respective instance fields is not necessary, it will be automatically handled. Compact constructor is the best place to put component validation and sanitization logic.
public record City(String countryCode, String cityCode) { // implementing compact constructor public City{ Objects.requireNonNull(cityCode); Objects.requireNonNull(cityCode); countryCode = countryCode.toLowerCase(); cityCode = cityCode.toLowerCase(); } }
Immutability of record fields
record fields are shallow immutable. That means, we can’t re assign to the instance fields. The objects held by the instance fields can be mutated by getting a reference to it. For example If a record field is holding a reference to a collection, mutating the collection is still possible. One way to prevent this is to create a immutable collection from the source collection in the compact constructor.
public record Country(String countryCode, Set<String> cityCodes) { public Country { cityCodes = Set.copyOf(cityCodes); } }
In the above code, we take immutable copy of the passed city-codes in the compact constructor. Now the records cityCodes object can’t be modified.
Inheritance
Records can’t extend other classes and other classes can’t extend a record class. But record classes can implement interfaces. The following record is implementing a single interface called GeoArea and it’s only method geoCode. Since record classes can’t be abstract, they have to implement all the methods of the interfaces they are implementing.
public interface GeoArea { String geoCode(); } public record City(String countryCode, String cityCode) implements GeoArea { @Override public String geoCode() { return countryCode() + ":" +cityCode(); } }
Local record classes
Record classes that are defined with in a method body are called local record classes. This type record classes can be used when there is local context that need to carry several data items as single entity. This really helps when using streams, I end up using Pair or Triple when I need to return more than one data item from a map function. Though the pair does the job, programmer has to manually keep of track what’s on left and what on right. If both the data items has same type, it’s easy to make mistakes. With local records, we can give meaningful names to the components and intent become explicit.
private void process(List<String> cityJsonStrings) { record CityStringPair(String rawJson, Optional<City> city) { } var cityStringPairs = cityJsonStrings.stream() .map(json -> new CityStringPair(json, toCity(json))) .toList(); // do something failures and the result }
Record serialisation with Jackson
Serialisation works similar to regular POJO classes. We can use object-mapper to convert objects to JSON strings and read objects from JSON strings. If there are derived getters, then they will also be present in the resulted JSON string. Annotations are allowed on records, so we can use @JsonIgnore to skip any derived getters during JSON serialisation. Components also accept Annotations, so we can customize the field keys in resulted JSON.
@JsonIgnoreProperties(ignoreUnknown = true) public record City(@JsonProperty("country_code") String countryCode, @JsonProperty("city_code") String cityCode) implements GeoArea { public City { Objects.requireNonNull(countryCode); Objects.requireNonNull(cityCode); } @Override @JsonIgnore public String getGeoCode() { return countryCode() + ":" +cityCode(); } } private Optional<City> toCity(String json) { try { return Optional.of(new ObjectMapper().readValue(json, City.class)); } catch (JsonProcessingException e) { } return Optional.empty(); } private Optional<String> toJson(City city) { try { return Optional.of(new ObjectMapper().writeValueAsString(city)); } catch (JsonProcessingException e) { } return Optional.empty(); }
Generated JSON looks like the following
{"country_code":"in","city_code":"ch"}
Calling toCity with above JSON will generate a valid City object. If any of the fields are not present, then jakson will fail to construct the city object due the null check present in the compact constructor.