Contents

A record is declared with the record keyword followed by a list of components in parentheses. Each component becomes a private final field plus a public accessor method with the same name (not getX(), just x()).

// Declaration — replaces ~50 lines of boilerplate record Point(int x, int y) {} // What the compiler auto-generates: // private final int x; // private final int y; // public Point(int x, int y) { this.x = x; this.y = y; } // public int x() { return x; } // public int y() { return y; } // public boolean equals(Object o) { ... } // public int hashCode() { ... } // public String toString() { return "Point[x=1, y=2]"; } Point p = new Point(3, 7); System.out.println(p.x()); // 3 System.out.println(p.y()); // 7 System.out.println(p); // Point[x=3, y=7] // equals() compares all components by value Point p2 = new Point(3, 7); System.out.println(p.equals(p2)); // true // Records work perfectly in collections and as map keys Set<Point> set = new HashSet<>(); set.add(new Point(1, 2)); set.add(new Point(1, 2)); // duplicate — not added System.out.println(set.size()); // 1 Record fields are always private final and there are no setters. Records are inherently immutable — this makes them thread-safe and safe to use as map keys.

A compact constructor lets you add validation or normalization logic without repeating the parameter assignments — the compiler inserts the assignments automatically at the end.

record Range(int min, int max) { // Compact constructor — no parameter list, no field assignments needed Range { if (min > max) throw new IllegalArgumentException( "min (%d) must be <= max (%d)".formatted(min, max)); // Compiler inserts: this.min = min; this.max = max; } } record Person(String name, int age) { Person { Objects.requireNonNull(name, "name must not be null"); name = name.strip(); // normalize before assignment if (age < 0) throw new IllegalArgumentException("age must be >= 0"); } } Person p = new Person(" Alice ", 30); System.out.println(p.name()); // "Alice" — stripped

You can also override the canonical constructor explicitly, but compact constructors are cleaner for validation:

record Coordinate(double lat, double lon) { // Explicit canonical constructor (less common — prefer compact) Coordinate(double lat, double lon) { if (lat < -90 || lat > 90) throw new IllegalArgumentException("Invalid lat"); if (lon < -180 || lon > 180) throw new IllegalArgumentException("Invalid lon"); this.lat = lat; this.lon = lon; } }

Records can have instance methods, static methods, and static fields. They cannot have instance fields beyond their components.

record Money(long cents, String currency) { // Static factory method public static Money of(double amount, String currency) { return new Money(Math.round(amount * 100), currency); } // Instance method public Money add(Money other) { if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch"); return new Money(this.cents + other.cents, this.currency); } // Custom toString (overrides auto-generated) @Override public String toString() { return String.format("%s %.2f", currency, cents / 100.0); } // Static constant — allowed public static final Money ZERO_USD = new Money(0, "USD"); } Money price = Money.of(19.99, "USD"); Money tax = Money.of(1.60, "USD"); System.out.println(price.add(tax)); // USD 21.59

Records can implement interfaces but cannot extend classes (they implicitly extend java.lang.Record). This makes records excellent for implementing value-object interfaces.

interface Shape { double area(); double perimeter(); } record Circle(double radius) implements Shape { public double area() { return Math.PI * radius * radius; } public double perimeter() { return 2 * Math.PI * radius; } } record Rectangle(double width, double height) implements Shape { public double area() { return width * height; } public double perimeter() { return 2 * (width + height); } } // Works great with sealed interfaces for exhaustive pattern matching List<Shape> shapes = List.of(new Circle(5), new Rectangle(3, 4)); shapes.forEach(s -> System.out.printf("Area=%.2f%n", s.area()));

Records also work seamlessly with Comparable and other standard interfaces:

record Version(int major, int minor, int patch) implements Comparable<Version> { @Override public int compareTo(Version other) { int c = Integer.compare(this.major, other.major); if (c != 0) return c; c = Integer.compare(this.minor, other.minor); return c != 0 ? c : Integer.compare(this.patch, other.patch); } } var versions = List.of( new Version(2, 1, 0), new Version(1, 9, 3), new Version(2, 0, 1) ); versions.stream().sorted().forEach(System.out::println); // Version[major=1, minor=9, patch=3] // Version[major=2, minor=0, patch=1] // Version[major=2, minor=1, patch=0]
// Classic DTO use case — a record replaces 30+ lines of boilerplate record UserDto(long id, String username, String email) {} // API response wrapper record ApiResponse<T>(T data, String status, String message) { public static <T> ApiResponse<T> ok(T data) { return new ApiResponse<>(data, "OK", null); } public static <T> ApiResponse<T> error(String message) { return new ApiResponse<>(null, "ERROR", message); } } ApiResponse<UserDto> resp = ApiResponse.ok(new UserDto(1, "alice", "alice@example.com")); System.out.println(resp); // ApiResponse[data=UserDto[id=1, username=alice, email=alice@example.com], status=OK, message=null] Records cannot have instance fields beyond their components. Any state beyond what is declared in the component list is not allowed.