Java Records and Sealed Classes

Java 17 introduced records and sealed classes as standard features. Records eliminate boilerplate for simple data classes. Sealed classes restrict which classes can extend a type. Together, they enable cleaner domain modeling and more expressive code.

Records

A record is a compact way to declare a class whose main purpose is holding data. Java generates the constructor, getters, equals(), hashCode(), and toString() automatically.

Before Records

public 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 == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }
    
    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

With Records

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

That’s it. One line replaces 30+ lines of boilerplate.

What Records Generate

A record declaration generates:

  • Private final fields for each component
  • A canonical constructor with all components as parameters
  • Accessor methods (named after components, not getX())
  • equals() based on all components
  • hashCode() based on all components
  • toString() showing all components
public record User(String name, String email, int age) { }

// Usage
User user = new User("Alice", "alice@example.com", 30);

// Accessor methods (no 'get' prefix)
String name = user.name();
String email = user.email();
int age = user.age();

// Generated toString
System.out.println(user);  // User[name=Alice, email=alice@example.com, age=30]

// Generated equals
User user2 = new User("Alice", "alice@example.com", 30);
System.out.println(user.equals(user2));  // true

Compact Constructors

Validate or transform data with a compact constructor:

public record User(String name, String email, int age) {
    
    // Compact constructor - no parameter list
    public User {
        // Validate
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name is required");
        }
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        
        // Transform (normalize email)
        email = email.toLowerCase().trim();
        
        // No explicit assignment needed - happens automatically after this block
    }
}

Additional Constructors

Add convenience constructors that call the canonical constructor:

public record Rectangle(int width, int height) {
    
    // Convenience constructor for squares
    public Rectangle(int side) {
        this(side, side);  // Must call canonical constructor
    }
    
    // Computed property
    public int area() {
        return width * height;
    }
}

Instance Methods

Records can have methods:

public record Point(int x, int y) {
    
    public double distanceTo(Point other) {
        int dx = this.x - other.x;
        int dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
    
    public Point translate(int dx, int dy) {
        return new Point(x + dx, y + dy);  // Records are immutable, return new instance
    }
}

Static Members

public record Color(int red, int green, int blue) {
    
    // Static fields
    public static final Color BLACK = new Color(0, 0, 0);
    public static final Color WHITE = new Color(255, 255, 255);
    public static final Color RED = new Color(255, 0, 0);
    
    // Static factory method
    public static Color fromHex(String hex) {
        int r = Integer.parseInt(hex.substring(1, 3), 16);
        int g = Integer.parseInt(hex.substring(3, 5), 16);
        int b = Integer.parseInt(hex.substring(5, 7), 16);
        return new Color(r, g, b);
    }
    
    // Compact constructor for validation
    public Color {
        if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) {
            throw new IllegalArgumentException("Color values must be 0-255");
        }
    }
}

Records and Interfaces

Records can implement interfaces:

public interface Drawable {
    void draw();
}

public record Circle(int x, int y, int radius) implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing circle at (" + x + ", " + y + ") with radius " + radius);
    }
}

Record Restrictions

  • Records are implicitly final (cannot be extended)
  • Records cannot extend other classes (implicitly extend java.lang.Record)
  • All fields are final (immutable)
  • Cannot declare instance fields outside components
// These are NOT allowed:
public record BadRecord(int x) extends SomeClass { }  // Cannot extend

public record BadRecord(int x) {
    private int y;  // Cannot add instance fields
}

When to Use Records

Good use cases:

  • DTOs (Data Transfer Objects)
  • API request/response objects
  • Value objects in domain-driven design
  • Configuration objects
  • Compound keys for maps
  • Method parameters (when you need multiple related values)
// DTOs
public record UserDto(Long id, String name, String email) { }

// API responses
public record ApiResponse<T>(T data, String message, boolean success) { }

// Value objects
public record Money(BigDecimal amount, Currency currency) { }

// Compound map keys
Map<Coordinate, Tile> tiles = new HashMap<>();
record Coordinate(int x, int y) { }
tiles.put(new Coordinate(0, 0), startTile);

Sealed Classes

Sealed classes restrict which classes can extend them. You explicitly declare the permitted subclasses.

Basic Syntax

// Sealed class declares permitted subclasses
public sealed class Shape permits Circle, Rectangle, Triangle {
    // Common shape functionality
}

// Permitted subclasses must be final, sealed, or non-sealed
public final class Circle extends Shape {
    private final double radius;
    
    public Circle(double radius) {
        this.radius = radius;
    }
}

public final class Rectangle extends Shape {
    private final double width;
    private final double height;
    
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
}

public final class Triangle extends Shape {
    private final double base;
    private final double height;
    
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
}

Subclass Modifiers

Permitted subclasses must declare how they continue (or end) the hierarchy:

  • final: No further subclasses allowed
  • sealed: Can have subclasses, but must declare them with permits
  • non-sealed: Opens up for unrestricted inheritance
public sealed class Vehicle permits Car, Truck, Motorcycle {
}

// final - no further extension
public final class Motorcycle extends Vehicle {
}

// sealed - controlled extension continues
public sealed class Car extends Vehicle permits Sedan, SUV, SportsCar {
}

public final class Sedan extends Car { }
public final class SUV extends Car { }
public final class SportsCar extends Car { }

// non-sealed - open for any extension
public non-sealed class Truck extends Vehicle {
}

// Anyone can extend Truck
public class PickupTruck extends Truck { }
public class SemiTruck extends Truck { }

Sealed Interfaces

Interfaces can be sealed too:

public sealed interface Expression permits Constant, Variable, BinaryOp {
}

public record Constant(int value) implements Expression { }

public record Variable(String name) implements Expression { }

public sealed interface BinaryOp extends Expression permits Add, Subtract, Multiply {
}

public record Add(Expression left, Expression right) implements BinaryOp { }
public record Subtract(Expression left, Expression right) implements BinaryOp { }
public record Multiply(Expression left, Expression right) implements BinaryOp { }

Pattern Matching with Sealed Classes

Sealed classes work powerfully with switch pattern matching (Java 21):

public sealed interface Shape permits Circle, Rectangle, Triangle { }

public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }

// The compiler knows all possible Shape types
public double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
        // No default needed - compiler knows these are all possibilities
    };
}

The compiler verifies that all permitted types are handled. If you add a new Shape subclass, you’ll get compile errors wherever switches don’t handle it.

Sealed Classes in Same File

If all subclasses are in the same file, the permits clause is optional:

// All in one file - permits is inferred
public sealed class Result {
}

final class Success extends Result {
    private final Object value;
    Success(Object value) { this.value = value; }
    Object value() { return value; }
}

final class Failure extends Result {
    private final Exception error;
    Failure(Exception error) { this.error = error; }
    Exception error() { return error; }
}

Records + Sealed Classes Together

Combining records and sealed classes creates powerful, type-safe domain models:

// Algebraic data type for JSON values
public sealed interface JsonValue permits JsonString, JsonNumber, JsonBoolean, 
                                         JsonNull, JsonArray, JsonObject {
}

public record JsonString(String value) implements JsonValue { }

public record JsonNumber(double value) implements JsonValue { }

public record JsonBoolean(boolean value) implements JsonValue {
    public static final JsonBoolean TRUE = new JsonBoolean(true);
    public static final JsonBoolean FALSE = new JsonBoolean(false);
}

public record JsonNull() implements JsonValue {
    public static final JsonNull INSTANCE = new JsonNull();
}

public record JsonArray(List<JsonValue> values) implements JsonValue {
    public JsonArray {
        values = List.copyOf(values);  // Defensive copy
    }
}

public record JsonObject(Map<String, JsonValue> members) implements JsonValue {
    public JsonObject {
        members = Map.copyOf(members);  // Defensive copy
    }
}

// Process any JSON value
public String stringify(JsonValue value) {
    return switch (value) {
        case JsonString s -> "\"" + s.value() + "\"";
        case JsonNumber n -> String.valueOf(n.value());
        case JsonBoolean b -> String.valueOf(b.value());
        case JsonNull ignored -> "null";
        case JsonArray a -> "[" + a.values().stream()
                .map(this::stringify)
                .collect(Collectors.joining(", ")) + "]";
        case JsonObject o -> "{" + o.members().entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\": " + stringify(e.getValue()))
                .collect(Collectors.joining(", ")) + "}";
    };
}

Result Type Pattern

public sealed interface Result<T> permits Result.Success, Result.Failure {
    
    record Success<T>(T value) implements Result<T> { }
    
    record Failure<T>(String error) implements Result<T> { }
    
    static <T> Result<T> success(T value) {
        return new Success<>(value);
    }
    
    static <T> Result<T> failure(String error) {
        return new Failure<>(error);
    }
}

// Usage
public Result<User> findUser(Long id) {
    User user = repository.findById(id);
    if (user == null) {
        return Result.failure("User not found: " + id);
    }
    return Result.success(user);
}

// Handle result
Result<User> result = findUser(123L);
String message = switch (result) {
    case Result.Success<User> s -> "Found: " + s.value().getName();
    case Result.Failure<User> f -> "Error: " + f.error();
};

Command Pattern

public sealed interface Command permits CreateUser, UpdateUser, DeleteUser {
}

public record CreateUser(String name, String email) implements Command { }
public record UpdateUser(Long id, String name, String email) implements Command { }
public record DeleteUser(Long id) implements Command { }

public class CommandHandler {
    public void handle(Command command) {
        switch (command) {
            case CreateUser c -> createUser(c.name(), c.email());
            case UpdateUser u -> updateUser(u.id(), u.name(), u.email());
            case DeleteUser d -> deleteUser(d.id());
        }
    }
}

Migration Tips

Converting POJOs to Records

Good candidates for records:

// Before - typical DTO
public class UserDto {
    private final Long id;
    private final String name;
    private final String email;
    
    // Constructor, getters, equals, hashCode, toString...
}

// After - one line
public record UserDto(Long id, String name, String email) { }

Poor candidates (keep as classes):

  • Entities with JPA annotations (records don’t work well with JPA)
  • Mutable objects that need setters
  • Classes requiring inheritance
  • Classes with complex construction logic

Introducing Sealed Hierarchies

// Before - open hierarchy, unclear what subtypes exist
public abstract class PaymentMethod {
}

public class CreditCard extends PaymentMethod { }
public class BankTransfer extends PaymentMethod { }
// What else? Who knows...

// After - closed hierarchy, all types explicit
public sealed abstract class PaymentMethod 
    permits CreditCard, BankTransfer, DigitalWallet {
}

public final class CreditCard extends PaymentMethod { }
public final class BankTransfer extends PaymentMethod { }
public final class DigitalWallet extends PaymentMethod { }

Summary

Records eliminate boilerplate for immutable data classes. Declare components in the header, and Java generates the rest. Use them for DTOs, value objects, and anywhere you need simple data containers.

Sealed classes control inheritance hierarchies. Declare which classes can extend a type, and the compiler ensures exhaustive handling in switch expressions. Use them when you have a fixed set of subtypes that shouldn’t change arbitrarily.

Together, they enable algebraic data types in Java, bringing patterns from functional programming to mainstream Java development.


Prerequisites: Java Classes and Objects | Java Inheritance | Java Interfaces

Related: Java Design Patterns | Introduction to Java Generics

Sources

  • Oracle. “Record Classes.” docs.oracle.com/en/java/javase/21/language/records.html
  • Oracle. “Sealed Classes.” docs.oracle.com/en/java/javase/21/language/sealed-classes-and-interfaces.html
  • Goetz, Brian. “Data Classes and Sealed Types for Java.” openjdk.org/jeps/395
Scroll to Top