Records & Sealed Classes
For decades, Java was criticized for its verbosity. Creating a simple data carrier meant writing pages of getters, equals(), hashCode(), and toString() methods.
Java 14 (preview) and Java 16 (standard) introduced Records to solve the data carrier problem. Java 15 (preview) and Java 17 (standard) introduced Sealed Classes to solve the hierarchy control problem. Together, they enable Algebraic Data Types (ADTs) in Java, a concept borrowed from functional languages like Haskell and Scala.
1. Records: The Boilerplate Killer
A Record is a special kind of class in Java designed to be a transparent carrier for immutable data. It is not just a “struct”; it is a class with semantic guarantees.
The Problem: The “Java Bean”
Before Records, a simple Point class required ~50 lines of code to be correct (implementing equals, hashCode, toString properly).
The Solution: Record
public record Point(int x, int y) {}
That’s it. This single line generates:
- Private final fields (
x,y). - Canonical Constructor (accepting
xandy). - Accessors (
x(),y()) - Note: Nogetprefix! equals()andhashCode()(value-based equality).toString()(printsPoint[x=1, y=2]).
Interactive Demo: The Boilerplate Crusher
Evolution of Data Classes
2. Deep Dive: Why Immutability?
Records are shallowly immutable. This means the reference fields inside a record cannot be reassigned, but the objects they point to can be mutable (e.g., a Record holding an ArrayList).
Why is this important?
- Thread Safety: Immutable objects are automatically thread-safe. You can pass a Record between threads without synchronization.
- HashMap Keys: Mutable objects make terrible Map keys (if the object changes, its hash code changes, and you lose it in the Map). Records are stable.
- Cache Locality: Immutable data structures are often more compact in memory.
Under the Hood: invokedynamic
You might think the compiler just generates a giant equals() method. It doesn’t!
If you decompile a Record, you’ll see ObjectMethods.bootstrap. Java uses invokedynamic to link the equals, hashCode, and toString methods at runtime. This reduces the size of the bytecode and allows the JVM to optimize the implementation in future versions without recompiling your code.
3. Advanced Record Features
Records are not just data holders; they are robust classes.
Compact Constructor
You can validate data without repeating parameters. This is the only place you should put validation logic for a Record.
public record Range(int start, int end) {
// Compact constructor - no parameters!
public Range {
if (end < start) {
throw new IllegalArgumentException("End must be >= Start");
}
// Implicitly: this.start = start; this.end = end;
}
}
Static Fields and Methods
Records can have static members, useful for factories.
public record User(String id, String email) {
public static User anonymous() {
return new User("anon", "no-reply@example.com");
}
}
[!WARNING] Records cannot extend other classes (they implicitly extend
java.lang.Record). They can implement interfaces.
4. Sealed Classes: Controlled Inheritance
Inheritance is powerful but dangerous. Before Java 17, a class was either final (no subclasses) or open to everyone (any subclass).
Sealed Classes allow you to restrict which classes can extend your class. This gives you control over your API and enables the compiler to reason about your types.
Syntax
Use the sealed modifier and the permits clause.
public sealed interface PaymentResult permits Success, Failure, Pending {
}
The Three Rules of Subclasses
Any class that extends a sealed class must be one of:
final: Cannot be extended further (closes the hierarchy).sealed: Continues the restriction (must havepermits).non-sealed: Breaks the seal (open for extension by anyone).
// 1. Final: End of the line
public final class Success implements PaymentResult {}
// 2. Sealed: Restricted hierarchy continues
public sealed class Failure implements PaymentResult permits Timeout, Rejected {}
// 3. Non-Sealed: Open again
public non-sealed class Pending implements PaymentResult {}
Visualizing Hierarchy
5. The Power Combo: Records + Sealed Interfaces
When you combine Records (data) with Sealed Interfaces (types), you get Algebraic Data Types (ADTs). This allows the compiler to check exhaustiveness in switch expressions.
public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T data) implements Result<T> {}
public record Failure<T>(Throwable error) implements Result<T> {}
// Usage
public String handleResult(Result<String> result) {
return switch (result) {
case Success(var data) -> "Got data: " + data;
case Failure(var err) -> "Error: " + err.getMessage();
// No default needed! Compiler knows these are the only two options.
};
}
This pattern is extremely common in modern Java for error handling, replacing exceptions for control flow. It forces you to handle the error case, unlike unchecked exceptions.
6. Summary
- Records: Use for immutable DTOs, messages, and map keys. Use compact constructors for validation.
- Sealed Classes: Use for domain models where the types are known and finite (e.g.,
PaymentStatus,Shape,Result). - Best Practice: Prefer
finalsubclasses (usually Records) for sealed hierarchies to keep the design tight and predictable.
[!TIP] Records vs Lombok: Lombok is a library that hacks the compiler to generate code. Records are a native language feature. Always prefer Records unless you specifically need mutability (e.g., for JPA Entities).