Java records & compact constructors

#java Feb 16, 2022 5 min Mike Kowalski

Java records should be no longer considered as a “new concept”. Introduced in early 2020 (with version 14) have already been adopted in many projects and organizations. Yet, while we all know that they provide getters, equals, hashCode, and toString for free, there is one feature that still feels a bit unknown. Let’s see how compact constructors can help us write more idiomatic code.

Record construction

Recent versions of Java come with concise syntax for declaring records:

record Mapping(String from, String to) {}

Underneath, the compiler creates a corresponding canonical constructor with all the declared fields. We can say it’s syntactic sugar for a good-old “standard” constructor like this:

record Mapping(String from, String to) {
    Mapping(String from, String to) {
        this.from = from;
        this.to = to;
    }
}

Of course, the “old” syntax is still supported - records are classes too. They may also have multiple constructors, but they can’t avoid calling full canonical constructor (initializing all the fields). That’s why the code below causes a compilation failure:

// causes compilation error

record Point(int x, int y) {
    Point(int x, int y) { // boring!
        this.x = x;
        this.y = y;
    }
    
    Point(int x) {  // a bit weird...
        this(x, 0); // ... but perfectly fine for the compiler
    }

    Point() {
        // fails with: 'constructor is not canonical, so its first
        //              statement must invoke another constructor'
    }
}

Additionally, at the time of writing (Java 17), an explicit canonical constructor is not allowed to have a more restrictive access level than the record itself.

// this won't compile as well!

record Name(String name) { // package-private 
    
    // fails with: 'invalid canonical constructor in record Name
    //              (attempting to assign stronger access privileges; 
    //              was package)'
    private Name(String name) {
        this.name = name;
    }

    static Name of(String name) {
        return new Name(name);
    }
}

So if you’re a fan of using static factory methods to avoid the new keyword, you either have to deal with the existence of the canonical one or switch to a standard class.

@odrotbohm
February 12, 2022
Plus a linguistic aspect: with values it’s often closer to how you speak. „an email address of [email protected]“ -> EmailAddress.of(…). I.e. the „new“ adds a bit more noise than a factory method I can shape to match the domain language.
February 12, 2022

Compact constructors

Back to the Point 😉 Often, the one-line record declaration will be more than enough. But what if we need to customize the record creation process? This is where compact constructors come into play:

record Mapping(String from, String to) {
    Mapping {
        // compact constructor!
    }
}

The compact constructor is not the same as the parameterless one (Mapping()). In fact, it’s not even causing the generation of an additional constructor! It’s more like a container for the code, that should be injected into the canonical constructor. The main benefit of using the new syntax is that we don’t have to specify all the parameters again.

Within the compact constructor, parameters are implicitly accessible. But unlike the standard constructors, we can’t use this to reference instance fields.

IntelliJ IDEA warning screenshot
IntelliJ IDEA warns us immediately when trying to read the instance field value via 'this'.

The compact constructor’s body is being invoked before the actual field values assignment happens. This is why the following code:

record Mapping(String from, String to) {
    Mapping {
        System.out.println(
            "Mapping from='" + this.from() + "', to='" + this.to() + "'"
        );
    }
}

prints:

Mapping from='null', to='null'

Still, we can reference the constructor’s parameter values by their names:

record Mapping(String from, String to) {
    Mapping {
        System.out.println("Mapping from='" + from + "', to='" + to + "'");
    }
}

Field normalization

Compact constructors allow us to customize (normalize) the value of a certain field in a concise way. Let’s imagine we need an object representing available API endpoints:

record ApiEndpoint(String name, HttpMethod method, String url) {
    ApiEndpoint {
        name = name.toUpperCase();
        // no need to "re-declare" 'method' 
        // no need to "re-declare" 'url'
    }
}

Within a compact constructor we can apply any transformations we need, overwriting only the fields we are interested in. In the example above, we wanted to make sure that the endpoint name is always upper case. Normalization we defined has been applied to the record’s name field:

var endpoint = new ApiEndpoint(
    "test", 
    HttpMethod.GET, 
    "https://my-awesome-app.com/test"
);
System.out.println(endpoint);

// prints:
// ApiEndpoint[name=TEST, method=GET, url=https://my-awesome-app.com/test]

Improving immutability

As stated in the JEP 395, records are classes that act as transparent carriers for immutable data. However, they provide only a “shallow immutability” - just like the final fields they’re using. If the parameter’s type is not immutable itself (like List or Date), the record won’t prevent modifying its value.

A compact constructor is the right place to ensure the “proper” immutability, by creating an immutable copy of such object:

record PostTags(UUID postId, List<String> tags) {
    PostTags {
        tags: List.copyOf(tags);
    }
}

Yet, features like the copy() method known from Kotlin data classes still have to be implemented manually.

Preserving invariants

Sometimes, there’s a kind of a contract for the data we are accepting. From the domain point of view, such invariants must always remain true. Probably the best way of defining them is to keep them close to the objects they apply to. With compact constructors, we can easily enforce such assertions within the record itself.

record DateRange(LocalDate from, LocalDate to) {
    DateRange {
        Objects.requireNonNull(from, "'from' date is required");
        
        if (to != null && from.isAfter(to)) {
            throw new IllegalArgumentException(
                "'from' date must be earlier than 'to' date"
            );
        }
    }
    
    DateRange(LocalDate from) {
        this(from, null); // will call compact constructor validations too!
    }
}

For some “standard” validations (e.g. “not null”, “not blank”, “min X”) we may want to use JEE Bean Validation API (annotations) instead. You can find more details about this approach in this interesting post on Gunnar Morling’s blog.

Summary

Java language maintainers had a clear vision of the new syntax responsibilities:

The intention of a compact constructor declaration is that only validation and/or normalization code need be given in the constructor body; the remaining initialization code is automatically supplied by the compiler.

Records’ compact constructors reduce the boilerplate even further, to focus on what truly matters. Business logic over field instantiation ceremonies!

Mike Kowalski

Software engineer believing in craftsmanship and the power of fresh espresso. Writing in & about Java, distributed systems, and beyond. Mikes his own opinions and bytes.