Index

Records Syntax (Java 14)

Java Syntax

12.1 Declaring Records

Introduced in Java 14 as a preview feature and standardized in Java 16, records provide a compact syntax for declaring classes whose main purpose is to carry immutable data. Records automatically generate much of the boilerplate code that traditional Java classes require, such as constructors, getters, equals(), hashCode(), and toString(). This makes records ideal for modeling simple data aggregates with minimal fuss.

Basic Syntax

Declaring a record is straightforward using the record keyword followed by the record name and a header listing its components (fields):

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

This single line defines a record named Point with two components, x and y, both of type int.

What the Record Provides Automatically

When you declare a record like the above, Java implicitly provides the following:

These generated methods ensure your record is a fully functional, immutable data carrier with minimal manual coding.

Using the Record

Here's an example of how to use the Point record:

public class Main {
    public static void main(String[] args) {
        Point p1 = new Point(3, 5);

        System.out.println(p1.x()); // Prints: 3
        System.out.println(p1.y()); // Prints: 5
        System.out.println(p1);     // Prints: Point[x=3, y=5]

        Point p2 = new Point(3, 5);
        System.out.println(p1.equals(p2)); // Prints: true
    }
}

Notice that to access the values, you call x() and y(), not getX() or getY(). This naming convention differs from traditional JavaBeans getters but is consistent across all records.

Click to view full runnable Code

public class Main {

    // Record declaration
    public record Point(int x, int y) {}

    public static void main(String[] args) {
        Point p1 = new Point(3, 5);

        System.out.println("x: " + p1.x());     // Accessor method
        System.out.println("y: " + p1.y());
        System.out.println("Point: " + p1);     // toString()

        Point p2 = new Point(3, 5);
        System.out.println("p1 equals p2? " + p1.equals(p2));  // equals()
        System.out.println("HashCode of p1: " + p1.hashCode()); // hashCode()
    }
}

Immutability by Design

Records are implicitly final, which means you cannot extend a record class. Each component is also final and private under the hood, so once a record instance is created, its data cannot be changed.

This design enforces immutability, making records ideal for representing values or data transfer objects (DTOs) where thread safety and predictability are important.

Reflection on the Intent of Records

Records represent a significant evolution in Java's approach to data modeling:

Limitations and Considerations

While records simplify many cases, they are not a replacement for all classes:

In summary, records offer a powerful, concise way to define immutable data structures with built-in functionality, making your Java code cleaner, more maintainable, and expressive. This modern feature aligns Java with other languages that have had similar constructs for years, such as Kotlin's data classes or C#'s records.

Index

12.2 Compact Constructors

Records in Java automatically generate a canonical constructor matching the record components, which assigns parameter values to fields. However, sometimes you need to add validation or transformation logic when creating instances. For this purpose, Java allows you to define a compact constructor inside a record.

What Is a Compact Constructor?

A compact constructor is a concise way to add code to the canonical constructor without repeating the parameter list or field assignments. It uses the record's component names directly and automatically assigns parameters to fields unless you explicitly override the assignments.

Example: Compact Constructor with Validation

public record Person(String name, int age) {
    public Person {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be null or blank");
        }
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        // Fields 'name' and 'age' are automatically assigned after this block
    }
}

In this example:

Comparison with Canonical Constructors

A canonical constructor requires full parameter declaration and explicit field assignment:

public record Person(String name, int age) {
    public Person(String name, int age) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be null or blank");
        }
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        this.name = name;
        this.age = age;
    }
}

While this works, it is more verbose and repetitive, especially for records with many components.

Click to view full runnable Code

public class Main {

    public record Person(String name, int age) {
        public Person {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("Name cannot be null or blank");
            }
            if (age < 0) {
                throw new IllegalArgumentException("Age cannot be negative");
            }
            // Fields are assigned automatically
        }
    }

    public static void main(String[] args) {
        try {
            Person p1 = new Person("Alice", 30);
            System.out.println("Created person: " + p1);

            Person p2 = new Person("", 25); // Triggers exception
            System.out.println("Created person: " + p2);
        } catch (IllegalArgumentException e) {
            System.out.println("Validation failed: " + e.getMessage());
        }

        try {
            Person p3 = new Person("Bob", -5); // Triggers exception
            System.out.println("Created person: " + p3);
        } catch (IllegalArgumentException e) {
            System.out.println("Validation failed: " + e.getMessage());
        }
    }
}

Reflection

Compact constructors keep records concise yet flexible by allowing you to insert validation, normalization, or other logic directly at the construction phase without sacrificing the brevity that makes records attractive. They promote clean and maintainable code, keeping the focus on data immutability and integrity.

Index

12.3 Records vs Classes

With the introduction of records in Java 14, a new concise syntax for data-carrying classes has emerged. Records provide a lightweight way to model immutable data aggregates while reducing boilerplate code common in plain old Java objects (POJOs). This section compares records with traditional classes, highlighting their syntax differences, capabilities, and limitations, and reflects on when to prefer records over classes.

Syntax Comparison

Traditional Class:

public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String name() {
        return name;
    }

    public int age() {
        return age;
    }

    @Override
    public String toString() {
        return "Person[name=" + name + ", age=" + age + "]";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person other = (Person) o;
        return age == other.age && name.equals(other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

Record:

public record Person(String name, int age) {}

Notice how a record declaration is dramatically more concise, automatically generating:

What Records Can Do

What Records Cannot Do

When to Use Records

When to Prefer Classes

Reflection

Records introduce a powerful tool for Java developers to write clearer, safer, and more maintainable code by focusing on immutable data and automatically handling common tasks like equality and string representation. They promote value-based design and reduce boilerplate, which leads to fewer errors and more concise code.

However, they do not replace traditional classes for all scenarios. The limitations around immutability, inheritance, and customization mean classes are still essential for more complex or mutable domain models.

In summary, use records when your class primarily models data with fixed state, and prefer classes when your design requires richer behavior or mutability. This thoughtful use of both enables you to write idiomatic, clean, and robust Java code.

Index

12.4 Using Records for Data Aggregation

Records are an elegant and concise way to group related data into a single, immutable object. This feature makes them ideal for data aggregationβ€”combining multiple values into one logical unit that can be passed around your application safely and clearly.

Grouping Multiple Values: Example with Coordinates

Imagine you are working on a program that processes points on a 2D plane. Traditionally, you might create a class like this:

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

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

    public double getX() { return x; }
    public double getY() { return y; }

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

With records, this can be simplified drastically:

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

This single line creates a fully immutable type with two components, x and y, and automatically generates constructor, accessor methods, equals(), hashCode(), and toString().

Simplifying Method Returns

Records can make method signatures and implementations clearer by returning multiple values without needing to create separate classes manually:

public class Geometry {
    public static Point midpoint(Point a, Point b) {
        return new Point(
            (a.x() + b.x()) / 2,
            (a.y() + b.y()) / 2
        );
    }
}

Here, midpoint returns a Point record that clearly groups the two coordinates. Before records, you might have returned an array or a custom class, both less clear or more verbose.

Grouping Complex Data: User Information

Records also work well for aggregating more complex data, such as user profiles:

public record User(String username, String email, int age) {}

In this example, a User record can be used throughout your application to represent user data consistently, safely, and without risk of accidental modification.

Using Records in Collections

Records are especially powerful when used in collections like lists or maps:

List<User> users = List.of(
    new User("alice", "alice@example.com", 30),
    new User("bob", "bob@example.com", 25)
);

Map<String, Point> cityCoordinates = Map.of(
    "New York", new Point(40.7128, -74.0060),
    "Los Angeles", new Point(34.0522, -118.2437)
);

Because records provide reliable implementations of equals() and hashCode(), they work seamlessly as keys or values in collections without requiring extra code.

Records and Functional-Style Programming

Records promote functional programming principles in Java by emphasizing immutability and value-based equality. Since record components are final, you cannot change the state after construction, eliminating side effects and making your programs easier to reason about.

When paired with streams and lambda expressions, records enable concise, declarative code for data transformation and aggregation:

List<User> adults = users.stream()
    .filter(user -> user.age() >= 18)
    .collect(Collectors.toList());

This code filters a list of immutable User records to those who are adults, leveraging the safety and clarity records provide.

Click to view full runnable Code

import java.util.*;
import java.util.stream.Collectors;

public class Main {

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

    public record User(String username, String email, int age) {}

    public static class Geometry {
        public static Point midpoint(Point a, Point b) {
            return new Point(
                (a.x() + b.x()) / 2,
                (a.y() + b.y()) / 2
            );
        }
    }

    public static void main(String[] args) {
        // Example 1: Returning a record from a method
        Point p1 = new Point(2, 4);
        Point p2 = new Point(6, 8);
        Point mid = Geometry.midpoint(p1, p2);
        System.out.println("Midpoint: " + mid);

        // Example 2: Grouping data with a record
        List<User> users = List.of(
            new User("alice", "alice@example.com", 30),
            new User("bob", "bob@example.com", 17),
            new User("carol", "carol@example.com", 22)
        );

        System.out.println("\nAll users:");
        users.forEach(System.out::println);

        // Example 3: Using records in collections and functional style
        List<User> adults = users.stream()
            .filter(user -> user.age() >= 18)
            .collect(Collectors.toList());

        System.out.println("\nAdults:");
        adults.forEach(System.out::println);

        Map<String, Point> cityCoordinates = Map.of(
            "New York", new Point(40.7128, -74.0060),
            "Los Angeles", new Point(34.0522, -118.2437)
        );

        System.out.println("\nCity coordinates:");
        cityCoordinates.forEach((city, point) ->
            System.out.println(city + ": " + point));
    }
}

Reflection: The Role of Records in Modern Java

Records fill an important gap in Java by providing a native, language-level construct optimized for grouping data. They reduce boilerplate, improve readability, and support immutability, all of which are hallmarks of modern software design.

Using records for data aggregation helps developers:

In summary, records provide a streamlined way to represent aggregates of related data in Java, perfectly suited to both simple and complex use cases. Their design aligns with contemporary programming styles that prioritize immutability, clarity, and expressive code, making them an essential tool in any modern Java programmer's toolkit.

Index