19-07-2026 - Shai Zambrovski
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.
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:
private final field for the component.text(), not getText()).equals() and hashCode() implementations based on the component.toString() implementation that prints the record's name and its component.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]
}
}
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 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
}
}
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");
}
}
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
}
}
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);
}
}
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 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
}
}
}
Records fit great as immutable data carriers, but they are not a replacement for every class: