☕ Java

Records

Records, finalized in Java 16 (JEP 395, after preview in Java 14–15), are a special kind of class declared with the record keyword that compactly defines an immutable data carrier: listing the record's components once in its header automatically generates a canonical constructor, private final fields, public accessor methods, and correct equals(), hashCode(), and toString() implementations — eliminating the substantial boilerplate that writing an equivalent immutable value class by hand had always required, and which IDEs had long auto-generated but never eliminated from the source file itself. This entry covers exactly what boilerplate records eliminate and why that boilerplate existed in the first place, the canonical constructor and compact constructor syntax for validation, what records deliberately cannot do (extend a class, declare instance fields, be non-final or abstract) and why those restrictions exist, and records implementing interfaces alongside static factory methods.

Motivation — The Boilerplate of Hand-Written Immutable Data Classes

Before Java 16, writing a simple, genuinely immutable class to carry a fixed set of values — a Point with x and y, a Range with start and end, any small data-holding type with no behavior beyond holding and exposing its data — required writing out a substantial amount of mechanical, repetitive code: private final fields for each component, a constructor assigning each parameter to its matching field, a public accessor method per field, and, for the class to behave correctly as a value (usable in collections, comparable for equality, printable for debugging), an equals() method comparing every field, a hashCode() method combining every field, and a toString() method listing every field — none of which involves any actual decision-making once the fields are known, yet all of which had to be written, by hand or via IDE generation, and then kept in sync if a field were ever added, renamed, or removed. This boilerplate was widely recognized as both tedious and a genuine source of bugs: a field added to the constructor and accessor list but forgotten in equals() or hashCode() produces a class that compiles fine but behaves incorrectly in subtle ways (two logically-equal instances are not == in a HashSet, for instance) that may not surface until much later. IDEs mitigated the tedium by auto-generating this code, but auto-generation doesn't eliminate the boilerplate from the source file — the generated code is still text sitting in the class, still needs to be regenerated and re-reviewed if fields change, and still inflates the apparent size and complexity of what is conceptually a very simple declaration. Java 16's record keyword addresses this directly: declaring record Point(int x, int y) {} in a single line gives the class private final fields x and y, a canonical constructor accepting both, accessor methods x() and y() (named after the components themselves, not getX()/getY(), a deliberate departure from the JavaBeans naming convention), and equals(), hashCode(), and toString() implementations that correctly consider both components — all generated by the compiler from that one header line, with zero possibility of the equals()/hashCode()/field-list inconsistency bug described above, since the compiler derives all of them from the same single source of truth.
Java
// ── Before Java 16 — the full hand-written equivalent ──────────────────
public final class PointOld {
    private final int x;
    private final int y;

    public PointOld(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 == null || getClass() != o.getClass()) return false;
        PointOld that = (PointOld) o;
        return x == that.x && y == that.y;
    }

    @Override
    public int hashCode() { return Objects.hash(x, y); }

    @Override
    public String toString() { return "PointOld[x=" + x + ", y=" + y + "]"; }
}
// ~25 lines of entirely mechanical code, all fully determined by "x and y"

// ── Java 16+ — the exact same behavior, one line ────────────────────────
record Point(int x, int y) {}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.x());          // 1 — accessor named after the component, not getX()
System.out.println(p1.equals(p2));   // true — compiler-generated, considers both components
System.out.println(p1.hashCode() == p2.hashCode());   // true
System.out.println(p1);               // Point[x=1, y=2] — compiler-generated toString()

Canonical Constructor, Compact Constructor, and Validation

Every record implicitly has a canonical constructor — a constructor whose parameter list matches the record's component list exactly, in the same order — which the compiler generates automatically if the record doesn't declare one itself, simply assigning each parameter to its corresponding field with no additional logic. When a record's components need validation or normalization before being stored (rejecting an invalid value, trimming a string, clamping a number to a valid range), the record can declare a compact constructor: a constructor-like declaration using the record's name but with no parameter list of its own (since the parameters are already implicitly defined by the record header), inside which validation or transformation logic can be written, and where assignment to the fields happens implicitly at the end of the compact constructor's body — there's no this.x = x; to write, since the compiler still performs that final assignment automatically once the compact constructor's own code has run. This means a compact constructor is specifically for validating or transforming the incoming parameters before they're stored, not for arbitrary constructor logic with full control over what gets assigned — the parameters themselves can be reassigned within the compact constructor's body (e.g. trimmed or clamped), and whatever their value is at the end of the compact constructor's body is what gets assigned to the corresponding field, but a compact constructor cannot selectively skip assigning one of the fields or assign some different, unrelated value to a field that doesn't correspond to any parameter. A record can also declare additional, non-canonical constructors with different parameter lists (overloaded constructors), but every one of those non-canonical constructors must, as its very first statement, delegate to the canonical constructor (via this(...)) — directly or through a chain of other non-canonical constructors that eventually reaches it — ensuring that whatever validation logic lives in the canonical/compact constructor is always actually executed, no matter which constructor overload a caller happens to use, which prevents a record's validation guarantees from being silently bypassed by an alternate constructor path.
Java
// ── Compact constructor — validation without re-listing parameters ─────
record Range(int start, int end) {
    Range {   // compact constructor — no parameter list (already implied by the header)
        if (start > end) {
            throw new IllegalArgumentException("start must be <= end: " + start + " > " + end);
        }
        // No "this.start = start;" needed — implicit assignment happens automatically
        // AFTER this compact constructor body finishes running
    }
}

Range valid = new Range(1, 10);          // fine
// Range invalid = new Range(10, 1);     // throws IllegalArgumentException immediately

// ── Compact constructor can transform/normalize parameters before storing
record Username(String value) {
    Username {
        value = value.strip().toLowerCase();   // reassigning the parameter itself —
        // whatever "value" holds at the end of this block is what gets stored in the field
        if (value.isBlank()) {
            throw new IllegalArgumentException("Username cannot be blank");
        }
    }
}

Username u = new Username("  AlIcE  ");
System.out.println(u.value());   // "alice" — trimmed and lowercased by the compact constructor

// ── Additional non-canonical constructors must delegate to the canonical one
record Point3(int x, int y, int z) {
    Point3(int x, int y) {
        this(x, y, 0);   // MUST delegate to the canonical constructor — directly or via chain
    }
    // Point3(int x, int y) { this.x = x; this.y = y; this.z = 0; }   // NOT ALLOWED —
    // non-canonical constructors cannot assign fields directly, only delegate via this(...)
}

Restrictions, Interface Implementation, and Static Factories

Records carry several deliberate restrictions, all in service of the same goal: guaranteeing that a record genuinely is what it appears to be — an immutable, transparent carrier for exactly its declared components, with no hidden extra state and no possibility of silently becoming mutable through inheritance. A record cannot extend any other class (every record implicitly extends java.lang.Record, an abstract class that itself cannot be extended directly by ordinary classes, and since Java permits only single class inheritance, a record extending Record leaves no room to also extend something else); a record is implicitly final and cannot be extended by any other class, preventing a subclass from adding extra mutable state that would undermine the "this record is exactly its listed components" guarantee; and a record cannot declare additional instance fields beyond its components — only the components themselves may hold per-instance state, though static fields are permitted, since those don't represent per-instance data. Despite these restrictions on extending classes, a record can implement any number of interfaces, exactly as an ordinary class can, which lets records participate in existing type hierarchies and abstractions (a record can implement Comparable, Serializable, or any domain-specific interface) while still gaining all of the automatic constructor/accessor/equals/hashCode/toString generation. Records frequently pair naturally with sealed interfaces specifically because a sealed interface's permitted implementations are very often simple, fixed-shape data variants — exactly what records are designed to express concisely — a pairing covered in depth separately. Records can also declare static methods, including static factory methods, which is a common pattern for providing alternate, more descriptive ways to construct a record beyond its canonical constructor, or for pre-validating/computing derived values before constructing the instance — and, like any class, can declare additional instance methods beyond the compiler-generated accessors, allowing a record to carry genuine behavior alongside its data, as long as that behavior doesn't require any extra mutable state beyond the record's own components.
Java
// ── Records cannot extend a class — implicitly extend java.lang.Record ──
// record Bad extends SomeClass { }   // COMPILE ERROR — records cannot extend any class

// ── Records are implicitly final — cannot be extended ──────────────────
record Point(int x, int y) {}
// class ExtendedPoint extends Point { }   // COMPILE ERROR — Point is implicitly final

// ── No additional INSTANCE fields beyond the declared components ──────
record Counter(int value) {
    // private int extraInstanceField;   // COMPILE ERROR — no extra instance state allowed
    private static int totalCreated = 0;   // STATIC fields ARE allowed — not per-instance state
}

// ── Records CAN implement interfaces ────────────────────────────────────
record Point(int x, int y) implements Comparable<Point> {
    @Override
    public int compareTo(Point other) {
        return Integer.compare(this.x * this.x + this.y * this.y,
                                 other.x * other.x + other.y * other.y);
    }
}

// ── Static factory methods and additional instance methods ────────────
record Temperature(double celsius) {
    static Temperature fromFahrenheit(double f) {
        return new Temperature((f - 32) * 5.0 / 9.0);   // alternate construction path
    }

    // Ordinary instance method — genuine behavior alongside the data:
    double toFahrenheit() {
        return celsius * 9.0 / 5.0 + 32;
    }
}

Temperature t = Temperature.fromFahrenheit(98.6);
System.out.println(t.celsius());        // ~37.0
System.out.println(t.toFahrenheit());   // 98.6

Related Topics in Java 9 to 21 Features

Module System (JPMS)
The Java Platform Module System (JPMS), introduced in Java 9 under Project Jigsaw, adds a module as a new, higher-level unit of code organization above the package, allowing a JAR-like artifact to explicitly declare which packages it exports for use by other modules, which modules it depends on, and which packages it keeps entirely internal and inaccessible from outside — closing a long-standing gap in the classpath model where every public class in every JAR on the classpath was accessible to every other JAR, regardless of whether that access was intended. JPMS was also the mechanism used to decompose the JDK itself, which had grown into one monolithic rt.jar, into roughly 90 separate modules, enabling smaller custom runtime images and explicit internal-API encapsulation (notably restricting access to sun.* and other internal packages that many libraries had relied on despite warnings). This entry covers the classpath problems JPMS solves, module-info.java syntax and the requires/exports/opens directives, the distinction between the classpath and module path including automatic and unnamed modules, and jlink custom runtime images.
JShell
JShell, introduced in Java 9, is an interactive Read-Eval-Print Loop (REPL) bundled directly with the JDK, allowing Java statements, expressions, method, class, and variable declarations to be entered and evaluated one at a time at an interactive prompt, with immediate feedback — without requiring a class wrapper, a main method, explicit compilation, or a build tool, all of which Java had always required for even the smallest snippet of executable code before Java 9. This solved a long-standing friction point for learning, quick experimentation, and API exploration, where languages like Python or Ruby offered an immediate interactive prompt but Java required the full edit-compile-run cycle even to test a single line. This entry covers what problem JShell solves and how it differs from a script, core REPL usage including implicit variable creation and forward references, snippet management commands, and how to feed JShell external classes and files.
Collection Factory Methods
Collection factory methods — List.of(...), Set.of(...), and Map.of(...)/Map.ofEntries(...) — introduced in Java 9, provide a concise, standard way to create small, fixed-content, genuinely immutable collections in a single expression, replacing the previously common but verbose combination of Arrays.asList(...), Collections.unmodifiableList(...), and manual add() calls, none of which produced a collection that was both concise to write and fully immutable. This entry covers exactly what problem these factories solve compared to the older idioms, the specific immutability and null-rejection guarantees they provide (which are stricter than Collections.unmodifiableXxx), their behavior around duplicate keys/elements, and important caveats around mutability expectations and performance for very small collections.
Private Interface Methods
Private interface methods, introduced in Java 9, allow an interface to declare methods marked private — with a full method body — that are accessible only from within that same interface (from its default methods, its other private methods, and its private static methods), but completely invisible to implementing classes and to any code outside the interface. They close a code-duplication gap left after Java 8's default methods: when an interface has multiple default methods that share common logic, that shared logic previously either had to be duplicated in each default method or exposed as a public default method purely to enable reuse, polluting the interface's actual API surface with a method meant only for internal reuse. This entry covers the specific problem they solve, the distinction between private instance and private static interface methods, the rules governing what they can call and be called from, and how they interact with the broader default-method design Java established in version 8.