Enums, Records & Sealed Types

Introducing Records

15 min Lesson 4 of 13

Introducing Records

Every Java developer has written a class whose sole purpose is to hold a handful of values and pass them around — a coordinate pair, an API response, a search result. Writing that class the traditional way means keyboard-filling a constructor, a field for every component, getters, equals(), hashCode(), and toString(). That is roughly 30–50 lines for what is conceptually just a named tuple. Java 16 made records a permanent language feature to solve this problem exactly.

The problem records solve

Consider a simple class that represents a 2-D point:

// Traditional approach — 30+ lines, all mechanical boilerplate public final class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Point p)) return false; return x == p.x && y == p.y; } @Override public int hashCode() { return Objects.hash(x, y); } @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; } }

Every time the design changes you touch multiple methods. The intent — "a point has an x and a y" — is buried under the implementation noise.

The record keyword

A record declaration names the class and its components in one line:

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

That single line gives you everything the 30-line version gave you:

  • Two private final fields, x and y.
  • A canonical constructorPoint(int x, int y) — that assigns both fields.
  • Accessor methods x() and y() (note: no get prefix — that is intentional).
  • A value-based equals() that compares all components.
  • A consistent hashCode() derived from all components.
  • A readable toString() that prints Point[x=3, y=7].
Records describe data, not behaviour. The key mental shift is that a record is a transparent data carrier: its API is its components. That is why the compiler generates everything from the component list alone.

Using a record

Point origin = new Point(0, 0); Point p = new Point(3, 7); System.out.println(p); // Point[x=3, y=7] System.out.println(p.x()); // 3 System.out.println(p.y()); // 7 System.out.println(origin.equals(new Point(0, 0))); // true System.out.println(p.equals(origin)); // false

Notice that equals is value-based: two separate Point objects with the same coordinates are equal, just as you would expect from a data class.

Records are implicitly final

A record class is final by default — you cannot extend it. You can implement interfaces, but you cannot create a subclass of a record. This is not a restriction; it is a deliberate design choice that keeps the meaning of a record clear: its identity is entirely defined by its components, and allowing subclasses to add hidden state would break that guarantee.

// This will NOT compile: // public class ColorPoint extends Point { } // error: cannot extend a record // This IS fine — implementing an interface: public interface Locatable { int x(); int y(); } public record Point(int x, int y) implements Locatable { }

Records are immutable by design

Every component field is private final. There are no setters generated. Once a record is constructed, its state cannot change. This immutability is one of the most important properties records bring — it makes them safe to share across threads and easy to reason about.

Use records as method parameters and return types freely. Because they are immutable and have a reliable equals() and hashCode(), they work correctly as Map keys, in Sets, and in collections — no defensive copying needed.

A second example: an HTTP response summary

public record HttpResponse(int statusCode, String body) { } HttpResponse ok = new HttpResponse(200, "Hello"); HttpResponse err = new HttpResponse(404, "Not found"); System.out.println(ok); // HttpResponse[statusCode=200, body=Hello] System.out.println(ok.statusCode()); // 200 // Value-based equality var a = new HttpResponse(200, "Hello"); var b = new HttpResponse(200, "Hello"); System.out.println(a.equals(b)); // true

What records are NOT

  • They are not JavaBeans — no getX() style accessors, no no-arg constructor, no setters.
  • They are not just a shorthand for any class — use records only when the class truly is its data (a result, a command, a coordinate, a range). If you need mutability or inheritance, use a regular class.
  • They are not value types (like C structs) — they are still heap-allocated reference objects in the current JVM.
Do not reach for records when you need controlled mutation. If your design requires changing a field after construction, a record is the wrong tool. A plain class with private fields and explicit setters is the right choice there.

Summary

Records are Java's answer to the boilerplate tax on immutable data classes. The record keyword replaces dozens of lines of mechanical code with a single declaration that clearly expresses what the type is. The compiler generates the canonical constructor, accessors, equals, hashCode, and toString for you — and the resulting type is final, immutable, and correct by construction. In the next lesson you will see how to add your own methods to a record and customise the generated constructor when you need to validate input.