Record class in Java

Introduction

Records are a new kind of classes introduced in the Java language. They help us model simple data aggregates with less ceremony or verbosity than normal classes.

In this post, we will first look at the regular (or usual) way to create a simple, immutable object (POJO i.e., Plain Old Java Object) and the problems it has. Then will see how Records solve the problems by introducing a new way to create a data-carrier class.

What are Records in Java

Records were first added as a preview feature in Java 14 (JEP-359) and as a second preview in Java 15 (JEP-384). It was added as a feature in Java 16 (JEP-395).

Records are object-oriented construct to express a class which represents an aggregation of values, i.e., a class which is a collection of values. We call such classes as data-carrier classes. Records will also automatically provide a way to initialize it and implement data-driven methods like accessors, equals, hashCode and toString. In short, using records, we would have a new way to concisely represent data carrier classes.

It was not in their goal to be against using mutable classes which use JavaBeans naming conventions. Nor were they trying to add properties or annotation-driven code generation (like Lombok) to the language.

Creating an immutable POJO

Let us look at how we normally would create a simple, immutable POJO class in Java. I use a Point class as an example. 

A Point object would have the x and y co-ordinate values. It will have a constructor and exposes public getter methods (accessor) for each of the fields. We will also have to implement equals, hashCode and toString methods. We need equals and hashCode if we want to check if two Point instances are equal and to use Point instances in data structures like HashMap. The toString is just for easier debugging (to print the string representation of the object). 

If we were to do all this, this is how it would look.

import java.util.Objects;

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point{" +
                "x=" + x +
                ", y=" + y +
                '}';
    }
}

Now who wouldn’t agree that this is verbose and has lots of boilerplate code? All we needed was an immutable data carrier for a handful of values. We have seen that we have to do a lot of work to write a simple data-carrier class. This is clearly very repetitive and error-prone.

Yes, the IDE can help with generation of the methods including the constructors. Or we can even use something like Lombok annotations to avoid writing these. But the latter is not part of Java language itself. 

Either way, having these additional methods doesn’t communicate the design intent of the class – “I’m a data carrier for x and y”.

What do Records offer

Records were added to the Java language to model an immutable data-carrier class in a simple way (to both write and read) and to avoid the error-prone boilerplate stuffs. The goal was not to simply avoid boilerplate, but they chose a goal to have a new and simpler way to model data as data.

The JEP states “It should be easy and concise to declare data-carrier classes that by default make their data immutable and provide idiomatic implementations of methods that produce and consume the data”

Declaring our first Record

Let us now replace our Point class with its equivalent record declaration.

public record Point(int x, int y) {
}

Simple and elegant!!

We start the declaration of the record with the record keyword, followed by the record name (Point). We follow this with the state of the record consisting of the fields (x and y here). Then we have an empty record body. 

These are the things we need to declare a minimum record class. We will explore the details of the record and explore other constructors and methods that we can add. But before that, let us create an instance of this record and see how it works by comparing with the normal POJO class.

Creating an instance of the Record

We create an instance of the record using the new operator (nothing new here as a record is just a class).

Point pointRecord = new Point(1, 2);

Once we have an instance of the record, we can access the fields using the accessor methods, as shown below. 
Note: Contrasting with the normal naming conventions of getters, this doesn’t have the get prefix. (x() vs getX()).

System.out.println(pointRecord.x()); //1
System.out.println(pointRecord.y()); //2

It also provides with default implementation of hashCode and toString method. The toString prints the name of the record followed by the values of the individual fields or data in the record.

System.out.println(pointRecord.hashCode()); //33
System.out.println(pointRecord); //Point[x=1, y=2]

Finally, we have the equals method as well. Comparing two Point instances is shown below.

Point pointRecord2 = new Point(1, 2);
System.out.println(pointRecord.equals(pointRecord2)); //true

Point pointRecord3 = new Point(1, 3);
System.out.println(pointRecord.equals(pointRecord3)); //false

Let us now get a bit theoretical and learn about the terminologies of a Record class in Java.

Record class in Java – Components and Terminologies

A declaration of a record consists of declaration of state. record class declaration consists of

  • a name (with optional type parameters)
  • a header
  • a body

The header lists the components of the record class (i.e., the variables or fields that make up the state). This is also called as the state description.

Once we declare a record class, it gets many standard members automatically.

  1. For each of the components in the state description (header), we get two members:
    1. public accessor method with the same name and return type as the component.
    2. private final field with the same type as the component.
  2. canonical constructor whose signature is the same as the header (follows the same order as well).
    1. This will take care of assigning values to each private field to the corresponding argument in the constructor.
  3. The equals and hashCode methods
    1. Two record values are equal if they are of the same type and contain equal values for each of the components.
  4. A toString method which returns a string representation of the record.

Constructors in a record class

All record classes get a default canonical constructor (if we don’t write an explicit constructor). For the Point record class, it would be an equivalent of,

record Point(int x, int y) {
    // Implicitly declared fields
    private final int x;
    private final int y;

    // Implicitly declared canonical constructor
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

We can declare a canonical constructor explicitly as well. In such case, the order of formal parameters in the constructor declaration must match the record’s header.

In the below example, we have a record to represent a range specified using low and high values. We have explicitly declared the canonical constructor. First, we check if the bounds are valid. Then, we assign each of the components of the record. Note that we don’t explicitly declare the instance variables or fields (low and high).

public record Range(int low, int high) {
    public Range(int low, int high) {
        if (high < low) {
            throw new IllegalArgumentException("High cannot be lesser than low");
        }
        this.low = low;
        this.high = high;
    }
}

If we remove the assignment statements, then we would get the below errors.

Record component 'low' might not be initialized in canonical constructor
Record component ‘high' might not be initialized in canonical constructor

Compact canonical constructor of a record class

In the above example, the only need to declare an explicit canonical constructor is to validate the low and high bound values. There is an option to declare the constructor in a compact way by eliding (omitting) the formal parameters list. It would look like

public record Range(int low, int high) {
    public Range {
        if (high < low) {
            throw new IllegalArgumentException("High cannot be lesser than low");
        }
    }
}

This is called as compact canonical constructor. The parameters are implicit here (in the same order as in the header of the record). 

Range range1 = new Range(1, 10);
System.out.println(range1.low()); //1
System.out.println(range1.high()); //10

//throws java.lang.IllegalArgumentException: High cannot be lesser than low
Range range2 = new Range(5, 4);

One important thing to notice here is when using the compact canonical constructor we cannot assign the private fields explicitly. They would be automatically assigned (this.low = low and this.high = high).

If we write the assignment this.low = low, we would get an error.
Cannot assign a value to final variable 'low'

The above is a good example of validating the arguments passed to the constructor  We can also use the compact version of the constructor to normalize the parameters. Example of a compact canonical constructor doing normalization is shown below.

record Rational(int num, int denom) {
    Rational {
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
    }
}

Again we don’t (and cannot) assign the parameters to the individual fields. The assignment made above are to local variables num and denom only.

Record class vs Normal class

Even though a record class is just another class, there are many differences between a normal class and a record class.

How record class differs from a normal class

  1. A record class declaration cannot have an extends clause.
    1. A normal class can extend from one superclass. 
    2. All classes in Java extend the java.lang.Object and we can declare a class even with an explicit extends clause with java.lang.Object.
    3. All record classes extend the java.lang.Record (abstract) class. But a record cannot explicitly extend any class (even for its implicit superclass java.lang.Record)
  2. All record classes are implicitly final and cannot be abstract.
    1. Hence a record class cannot be extended. This makes a record truly immutable.
  3. The fields of a record class (derived from the record components) are final.
  4. A record class cannot declare native methods.
  5. We cannot explicitly declare any instance variables (fields). We also cannot have any instance initializers.
    1. This implies that only the record header defines the state of the record. 

The last point implies that we cannot have anything like a derived field, which is derived from the components of a record as we wouldn’t be able to declare an instance variable. If we declare a private final field inside a record, we would get the below error:

Instance field is not allowed in record

Thus, if we have a need to derive or compute one of more fields (instance variables) from the components of a record, it is not a good use-case for a record and instead we must use a normal class.

Similarities of a record class and a normal class

Let us see ways in which a record class behaves like a normal class:

  • We create an instance of a record class using the new operator.
  • A record class cannot have instance variables or instance initializers as seen before, but they can have instance methods.

For example, in the Range record, we can have an instance method called getMid() which computes and returns the middle value of the range.

public record Range(int low, int high) {
    public int getMid() {
        return (low + high) / 2;
    }
}
Range range = new Range(1, 10);
System.out.println(range.getMid()); //5
  • A record class can implement one or more interfaces.

Let us say we have an interface for Shape as,

public interface Shape {
    int getHeight();
    int getWidth();
    int area();
}

We can create Square and Rectangle shapes as records as shown below.

public record Rectangle(int height, int width) implements Shape {
    @Override
    public int getHeight() {
        return height;
    }

    @Override
    public int getWidth() {
        return width;
    }

    @Override
    public int area() {
        return height * width;
    }
}
public record Square(int sideLength) implements Shape {
    @Override
    public int getHeight() {
        return sideLength;
    }

    @Override
    public int getWidth() {
        return sideLength;
    }

    @Override
    public int area() {
        return sideLength * sideLength;
    }
}
Square square = new Square(5);
Rectangle rectangle = new Rectangle(4, 5);
System.out.println(square.area()); //25
System.out.println(rectangle.area()); //20
  • A record class can have static methodsfields and initializers

Let us say we have a Color enum as shown below.

public enum Color {
    BLACK,
    BLUE,
    RED;
}

And we have a ColorTransformer record which we use to transform the enum to a string. But it only supports a subset of the available colors.

public record ColorTransformer() {
    private static final List<Color> SUPPORTED_COLORS;
    private static final Map<Color, String> MAP;

    static {
        MAP = Map.of(
                Color.BLUE, "Blue",
                Color.BLACK, "Black"
        );
        SUPPORTED_COLORS = List.copyOf(MAP.keySet());
    }

    public static List<Color> getSupportedColors() {
        return SUPPORTED_COLORS;
    }

    public static String getName(Color color) {
        return Optional.ofNullable(MAP.get(color))
                .orElseThrow(() -> new RuntimeException("Unsupported color"));
    }
}

We have two static final fields viz., to hold the mapping and the list of supported colors. The initialization of the static fields is done in a static initializer. Finally, we have static methods – getSupportedColors to return the list of colors supported and getName to get the name of a passed color enum.

System.out.println(ColorTransformer.getSupportedColors()); //[BLUE, BLACK]
System.out.println(ColorTransformer.getName(Color.BLUE)); //Blue

//throws java.lang.RuntimeException: Unsupported color
System.out.println(ColorTransformer.getName(Color.RED));

NoteMap.of method was added in Java 9 (see Convenience Factory Methods for Collections post) and List.copyOf is available in Java 10+.

  • A record class can be declared top level or nested and can be generic.
  • A record class can declare nested types, including nested record classes – We will see about this in the next section.

Local record classes

Before we look at local record classes, let me give a brief explanation about the nested classes in Java.

Nested classes in Java

A nested class is a class within another class. In Java, nested classes can be classified as shown below.

Nested Class
Nested Class

Static nested class

A static nested class does not have access to non-static members of the enclosing class. A static nested class is just like any other top-level class which has been placed (nested) inside another class for packaging convenience.

Inner class

A non-static nested class can access the fields and other members of the enclosing class. An instance of the inner class can only be created once we have an instance of the outer class. There are two kinds of inner classes – local classes and anonymous classes.

Nested class – example

public class OuterClass {
    private static final int MAX = 10;
    private String name;

    public OuterClass(String name) {
        this.name = name;
    }

    public class InnerClass {
        public void innerMethod() {
            System.out.println(name);
            System.out.println(MAX);
        }
    }

    public static class StaticNestedClass {
        public void method() {
            //Non-static field 'name' cannot be referenced from a static context
            //System.out.println(name);
            System.out.println(MAX);
        }
    }
}

We can create the inner class instance only after we have an instance of the outer class. The static nested class cannot access the instance variables or methods of the outer class.

OuterClass outerClass = new OuterClass("Java");

OuterClass.InnerClass innerClass = outerClass.new InnerClass();
innerClass.innerMethod(); 

OuterClass.StaticNestedClass staticNestedClass = new OuterClass.StaticNestedClass();
staticNestedClass.method();

Running this we get, 

Java
10
10

Local record class in action

Let us look at an example scenario and then see how we can use a local record class. We have a list of strings as shown below where each string has the id and the subject joined by a colon.

List<String> strings = List.of(
        "1:Java",
        "2:Computer Architecture",
        "3:Data structures",
        "4:Algorithms"
);

We have to convert it into a Map<String, String> with id as the key and the subject name as the value. Normally, we can do this as shown below.

Map<String, String> idToSubject = strings.stream()
        .map(s -> s.split(":"))
        .collect(Collectors.toMap(a -> a[0], a -> a[1]));
System.out.println(idToSubject);

The problem is with readability once we split the string into an array. In the collect step, we have to access the id and subject using the index. If there is more code, then accessing by index will result in a less readable code.

Another way we are used to is to create a static nested helper class as,

Map<String, String> idToSubject = strings.stream()
        .map(s -> {
            String[] parts = s.split(":");
            return new IdSubject(parts[0], parts[1]);
        })
        .collect(Collectors.toMap(idSubject -> idSubject.id, idSubject -> idSubject.name));
System.out.println(idToSubject);
private static class IdSubject {
    private final String id;
    private final String name;

    private IdSubject(String id, String name) {
        this.id = id;
        this.name = name;
    }
}

IdSubject is a static nested class created inside the class where we have this logic.

With local record classes, we can do better. It would be more readable and convenient to declare a local record class within the method to hold the intermediate values. Also, it would be closer to the code which needs it.

record IdSubject(String id, String name) {
}

Map<String, String> idToSubject = strings.stream()
        .map(s -> {
            String[] parts = s.split(":");
            return new IdSubject(parts[0], parts[1]);
        })
        .collect(Collectors.toMap(IdSubject::id, IdSubject::name));
System.out.println(idToSubject);

Local record classes are like the nested record classes, i.e., they are implicitly static. This means that from the record class, we cannot access any variables of the enclosing method and this avoids capturing an immediately enclosing instance (which would add state to the record class).

Note that a local record class is different from local classes. Local classes are not static (implicitly or explicitly). They can access the variables in the enclosing method.

Static members in an inner class

Before Java 16, we cannot declare a static member (implicitly or explicitly) in an inner class. Since nested record classes are implicitly static, this means that we cannot declare a record class member in an inner class. 

Hence, they have removed the restriction (from Java 16) that an inner class cannot have any static members. With this, an inner class can declare static members, which includes record classes as well.

public class OuterClass {

    public class InnerClass {
        // Now, inner class can have static declarations
        private static String STATIC_STRING = "abcd";
    }
}

If we compile the above code in Java 8, we would get the following error:

Static declarations in inner classes are not supported at language level '8'

Below code shows having a nested record (MyRecord) in an inner class (in Java 16+).

public class MyOuterClass {
    private static final int MAX = 10;
    private String name;

    
    public class InnerClass {
        record MyRecord() {
            public void someFunctionInRecord() {
                System.out.println(MAX);
            }
        }
        
        public void someFunction() {
            MyRecord myRecord = new MyRecord();
            myRecord.someFunctionInRecord();
        }
    }
}

Miscellaneous notes about constructors in a Record class

Before ending this post, I wanted to show a few other scenarios of how we can use a record and cover some of the ways in which we cannot use it.

Canonical constructor

As we have seen before, if we declare an explicit constructor, then it must have the same number and type of parameters as in the record header. Also, it must have the same name as well. In the below example, if we call the last component name with a different name, then it results in an error.

public record MyRecord(int a, String b) {

    public MyRecord(int a, String b1) {
        this.a = a;
        this.b = b1;
    }
}

The IDE shows the below error against the parameter b1.

Canonical constructor parameter names must match record component names. Expected: 'b', found: 'b1'

We cannot have both the canonical constructor and the compact canonical constructor together in a record.

Access modifier mismatch

When we have an explicitly declared canonical or compact canonical constructors, then their access modifier must provide at least as much access as the record class. For example, for a public record, if we make the constructor as package-private, we get the below error.

Canonical constructor access level cannot be more restrictive than the record access level ('public')

or

Compact constructor access level cannot be more restrictive than the record access level ('public')

Non-canonical constructors – Delegation

When we have a non-canonical constructor, we cannot do any assignment to the record component fields. Instead, they must delegate to another constructor (i.e., to another non-canonical constructor ora canonical constructor)

public record MyRecord(int a, String b) {
    public MyRecord(int a) {
        this.a = a;
        this.b = "b";
    }
}

The above constructor is not a canonical constructor, and hence it gives us the below error.

Non-canonical record constructor must delegate to another constructor

To fix it, we can delegate the call using this(..) to the implicit canonical constructor as shown below.

public record MyRecord(int a, String b) {
    public MyRecord(int a) {
        this(a, "b-value");
    }
}
MyRecord myRecord = new MyRecord(1);
System.out.println(myRecord.a()); //1
System.out.println(myRecord.b()); //b-value

We can also delegate to another non-canonical constructor as well. But ultimately, it should end up on a canonical or a compact canonical constructor. A canonical constructor cannot delegate to another constructor.

An example of using multiple non-canonical constructor which finally delegates to a canonical constructor is shown below. The record has two strings as the state components. But we support passing three or four strings from which we compute the two strings and delegate to the canonical constructor.

public record ChainRecord(String s1, String s2) {

    public ChainRecord(String a, String b, String c, String d) {
        this(a + b, c + d);
    }

    public ChainRecord(String a, String b, String c) {
        this(a + b, c);
    }
}

ChainRecord chainRecord = new ChainRecord("a", "b");
System.out.println(chainRecord.s1()); //a
System.out.println(chainRecord.s2()); //b

chainRecord = new ChainRecord("a", "b", "c");
System.out.println(chainRecord.s1()); //ab
System.out.println(chainRecord.s2()); //c

chainRecord = new ChainRecord("a", "b", "c", "d");
System.out.println(chainRecord.s1()); //ab
System.out.println(chainRecord.s2()); //cd

Conclusion

In this post, we learnt about the records class in Java. Records are a new kind of classes added to the Java language. We started with a simple record class and learnt about the various components of it. Then, we learnt about the canonical and compact constructors of a record and also saw how a record is similar to a normal class (and how it differs from it). Finally, we learnt about nested and local record classes.

Leave a Reply