Records and Sealed Classes

This section documents the current Java release line, with Java 25 LTS as the reference point (no specific patch version is pinned), as published at the Java developer portal, the Java Tutorials, and the Java SE API specification — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A record is a concise, immutable data carrier whose API is its state; a sealed type restricts which classes may extend or implement it, so the compiler knows the complete set of subtypes. Together they give Java algebraic data types. This page follows the dev.java "Using Records to Model Immutable Data" track and the JDK 25 language guides for records and sealed classes and interfaces.

Records

A record declares its components in the header. From record Point(int x, int y) \{ } the compiler generates a private final field per component, a public accessor named exactly after each component (x(), y() — no getX()), an all-arguments canonical constructor, and value-based equals, hashCode, and toString.

record Point(int x, int y) { }

var p = new Point(3, 4);
p.x();                        // 3    -- accessor, no "get" prefix
p.equals(new Point(3, 4));    // true -- compared component by component
p.toString();                 // Point[x=3, y=4]

A record is implicitly final, cannot extend another class (it already extends java.lang.Record), and has no instance initializer blocks and no non-static fields beyond its components. It can implement interfaces, declare static members, and declare additional instance methods.

Compact constructor: validation and normalization

The compact constructor has no parameter list and no field assignments. It runs before the implicit assignment of every component, so it is the place to validate arguments and to normalize values by reassigning the parameters.

import java.util.Objects;

record Range(int low, int high) {
    Range {                                   // compact: no (int low, int high), no this.low = ...
        if (low > high) {
            throw new IllegalArgumentException("low > high: " + low + " > " + high);
        }
    }
}

record Name(String value) {
    Name {
        value = Objects.requireNonNull(value, "value").strip();   // reassigning the parameter here
    }                                                            // normalizes the field that is stored
}

new Name("  Ada  ").value();   // "Ada"

Extra constructors, static factories, interfaces

interface Shape { double area(); }

record Circle(double radius) implements Shape {

    Circle {                                            // compact constructor: reject bad input
        if (radius <= 0) {
            throw new IllegalArgumentException("radius must be > 0");
        }
    }

    Circle() {                                          // extra constructor: must delegate
        this(1.0);
    }

    static Circle ofDiameter(double d) {                // static factory method
        return new Circle(d / 2);
    }

    @Override
    public double area() {                              // additional instance method
        return Math.PI * radius * radius;
    }

    static final Circle UNIT = new Circle(1.0);         // static field is allowed
}

Every non-compact constructor in a record must eventually reach the canonical constructor through this(…​).

When a Record Fits

Reach for a record when a value is defined entirely by its data, that data never changes after construction, and two instances with equal components should be considered equal.

// Fits a record: an immutable value, where identity == data
record Money(long amountCents, String currency) { }

// Not a record: it has an identity, mutable state, and lifecycle behaviour
final class ShoppingCart {
    private final java.util.List<Money> lines = new java.util.ArrayList<>();

    void add(Money line) { lines.add(line); }
    java.util.List<Money> lines() { return java.util.List.copyOf(lines); }
}
  • Prefer a normal class when the object has mutable state, has an identity independent of its fields (two accounts with the same balance are not "equal"), must extend another class, or needs to hide or derive fields.

  • Prefer a record over a Map<String, Object> or an Object[] "tuple" whenever the shape is known: you gain names, static types, immutability, and correct equals/hashCode for free, all checked at compile time.

  • A record is only shallowly immutable — a List component can still be mutated through an outside reference. Copy defensively in the compact constructor, or store List.copyOf(…​), when that matters.

Sealed Classes and Interfaces

A sealed type lists its permitted direct subtypes after permits (the clause may be omitted when every subtype is declared in the same source file). Each permitted subtype must itself be declared final, sealed, or non-sealed, and — in an unnamed module — must sit in the same package; in a named module it must sit in the same module.

sealed interface Expr permits Lit, Add, Neg { }

record Lit(double value)          implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Neg(Expr operand)          implements Expr { }

final closes a branch to further subclassing; sealed keeps it restricted further down; non-sealed re-opens it so that any code may extend that subtype.

sealed class Figure permits Disk, Polygon { }

final class Disk extends Figure { }            // no further subclasses

non-sealed class Polygon extends Figure { }    // re-opened: anyone may extend Polygon
class Hexagon extends Polygon { }              // ...like this, with no permits entry needed

Because the compiler knows every permitted subtype, a switch over a sealed type is exhaustive without a default once every subtype has a case — and it stops compiling when a new subtype is added but a switch is not updated to handle it.

Records + Sealed + Pattern Matching

A switch over a sealed hierarchy of records combines exhaustiveness with record deconstruction patterns: each case both tests the runtime type and binds the components in one step.

static double eval(Expr e) {
    return switch (e) {
        case Lit(double v)       -> v;
        case Add(Expr l, Expr r) -> eval(l) + eval(r);
        case Neg(Expr operand)   -> -eval(operand);
        // no default: Expr is sealed and every permitted type is covered
    };
}

var tree = new Add(new Lit(3), new Neg(new Lit(1)));
eval(tree);   // 2.0

Nested patterns (case Add(Lit(var a), Lit(var b))) and when guards work here too. The full treatment — instanceof patterns, unnamed patterns, guard clauses — is on Pattern Matching.

See Also

  • Pattern Matching — type patterns, record deconstruction, and guarded switch labels in depth.

  • Classes and Objects — the constructor, field, and equals/hashCode mechanics a record automates.

  • Interfaces — sealed interface and the interfaces a record can implement.

  • Enums — the other closed, exhaustively switchable type.