Java 17 Records

19-07-2026 - Shai Zambrovski


What is a Record?

A record is a special kind of class - introduced as preview in Java 14 and finalized as a standard feature in Java 16 - whose main purpose is to act as a transparent carrier for immutable data.

Before records, if we wanted a simple immutable data holder we had to write the constructor, the getters, equals(), hashCode() and toString() by hand (or let our IDE generate them), which leads to a lot of boilerplate for something that is conceptually very simple.

With records, the compiler generates all of that for us, based on the record's components.

We will use records when we want to model plain data - such as a DTO, a value object or the result of a computation - and we don't need the full flexibility of a regular class.

Declaring a Record

The syntax is straightforward, we declare the record's name and its components (the state) in the header, and the compiler will generate the rest.

public record TextContainer(String text) {
}

This single line is equivalent to writing a full class with:

public class Main {

    public static void main(String[] args) {
        TextContainer textContainer = new TextContainer("Hello shaikezam.com");

        System.out.println(textContainer.text()); // Hello shaikezam.com
        System.out.println(textContainer); // TextContainer[text=Hello shaikezam.com]
    }

}

Records are immutable

Every component of a record is implicitly private and final, there isn't really a way to declare a mutable field as part of the record's state.

That means once a record instance is created, its state can never be changed, only read through the generated accessor.

public class Main {

    public static void main(String[] args) {
        TextContainer textContainer = new TextContainer("Hello shaikezam.com");
        //textContainer.text = "another text"; // compilation error - there is no such field access, and no setter exists
    }

}

The Canonical Constructor

The constructor that takes all of the record's components, in the same order they are declared, is called the canonical constructor.

We can customize it - most commonly to validate the arguments - without having to repeat the field assignments, using the compact canonical constructor syntax.

public record TextContainer(String text) {

    public TextContainer {
        if (text == null || text.isBlank()) {
            throw new IllegalArgumentException("text should not be null or blank");
        }
        // no need to write this.text = text; - it happens implicitly at the end
    }

}
public class Main {

    public static void main(String[] args) {
        TextContainer textContainer = new TextContainer("Hello shaikezam.com"); // fine
        TextContainer invalidTextContainer = new TextContainer(""); // throws IllegalArgumentException
    }

}

Additional Constructors

Beside the canonical constructor, we can declare additional constructors, as long as they - directly or indirectly - delegate to the canonical constructor as their first statement.

public record TextContainer(String text) {

    public TextContainer {
        if (text == null || text.isBlank()) {
            throw new IllegalArgumentException("text should not be null or blank");
        }
    }

    public TextContainer() {
        this("Hello shaikezam.com");
    }

}

Additional Instance Methods

We are not limited to the generated methods, we can add our own instance methods to a record, as we would in a regular class.

public record TextContainer(String text) {

    public TextContainer toUpperCase() {
        return new TextContainer(text.toUpperCase());
    }

}
public class Main {

    public static void main(String[] args) {
        TextContainer textContainer = new TextContainer("Hello shaikezam.com");

        System.out.println(textContainer.toUpperCase().text()); // HELLO SHAIKEZAM.COM
    }

}

Static Fields and Methods

Records can also declare static fields and static methods, exactly as a regular class, which can be useful for constants or factory methods.

public record TextContainer(String text) {

    public static final TextContainer EMPTY = new TextContainer("");

    public static TextContainer of(String text) {
        return new TextContainer(text);
    }

}

Implementing Interfaces

A record can implement one or more interfaces, same as a class, it just cannot extend another class, since it implicitly extends java.lang.Record.

public interface Printable {
    void print();
}

public record TextContainer(String text) implements Printable {

    @Override
    public void print() {
        System.out.println(text);
    }

}
public class Main {

    public static void main(String[] args) {
        Printable printable = new TextContainer("Hello shaikezam.com");
        printable.print(); // Hello shaikezam.com
    }

}

Records and Pattern Matching

Records combine nicely with the instanceof pattern matching that was finalized in Java 16, letting us cast and bind the variable in the same expression.

public class Main {

    public static void main(String[] args) {
        Object obj = new TextContainer("Hello shaikezam.com");

        if (obj instanceof TextContainer textContainer) {
            System.out.println(textContainer.text().toUpperCase()); // HELLO SHAIKEZAM.COM
        }
    }

}

When not to use Records

Records fit great as immutable data carriers, but they are not a replacement for every class: