Records and sealed types
A shipping label has a fixed set of fields (name, street, city) and you never edit one in place; you print a new label. A record is a class for exactly that kind of data: you list the fields once, and Java writes the constructor, the accessors, equals, hashCode and toString.
A sealed interface is a closed list: “a payment result is either Approved, Declined or NeedsVerification, and nothing else”. Because the list is closed, the compiler can check that your code handles every case.
Records are transparent, shallowly immutable carriers with value-based equals/hashCode derived from components, canonical and compact constructors for validation, and deconstruction via record patterns. Sealed hierarchies (sealed … permits) give the compiler a closed set of subtypes, which enables exhaustive switch over types without default. Combined, they’re Java’s algebraic data types: model domain states as data, dispatch with pattern matching, and get a compile error when a new case appears. Kotlin has had the same with data classes and sealed classes; the semantics map almost one to one.
sealed interface PaymentResult
data class Approved(val transactionId: String, val amountCents: Long) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data class NeedsVerification(val redirectUrl: URI) : PaymentResult
data class Money(val cents: Long, val currency: String) {
init { require(cents >= 0) { "Negative amount" } }
}
fun message(result: PaymentResult): String = when (result) { // no else: the compiler checks all cases
is Approved -> "Paid, reference ${result.transactionId}"
is Declined -> "Declined: ${result.reason}"
is NeedsVerification -> "Please verify at ${result.redirectUrl}"
}public sealed interface PaymentResult permits Approved, Declined, NeedsVerification {}
public record Approved(String transactionId, long amountCents) implements PaymentResult {}
public record Declined(String reason) implements PaymentResult {}
public record NeedsVerification(URI redirectUrl) implements PaymentResult {}
public record Money(long cents, String currency) {
public Money { // compact constructor: validation only
if (cents < 0) throw new IllegalArgumentException("Negative amount");
currency = currency.toUpperCase();
}
}
String message(PaymentResult result) {
return switch (result) { // no default: the compiler checks all cases
case Approved a -> "Paid, reference " + a.transactionId();
case Declined d -> "Declined: " + d.reason();
case NeedsVerification(URI url) -> "Please verify at " + url; // record pattern
};
}record Money(long cents, String currency)gives younew Money(950, "eur"),money.cents(),money.currency(), andequals/hashCode/toStringbased on both fields. Fields are final: to “change” a record, create a new one.- The compact constructor (
public Money { … }) runs before the fields are assigned: validate and normalize there. sealed … permitslists every allowed implementation. In aswitchover aPaymentResult, the compiler knows there are exactly three cases, so you don’t need (and shouldn’t add)default. Add a fourth result type later, and everyswitchthat forgot it stops compiling.- Record patterns (
case NeedsVerification(URI url)) take the record apart right in thecase.
Tap the line with the bug.
data class AvatarKey(val userId: String, val fingerprint: ByteArray)
val cache = ConcurrentHashMap<AvatarKey, Image>()
fun avatar(userId: String, imageBytes: ByteArray): Image {
val key = AvatarKey(userId, sha256(imageBytes))
return cache.computeIfAbsent(key) { resize(imageBytes) }
}record AvatarKey(String userId, byte[] fingerprint) {}
Map<AvatarKey, Image> cache = new ConcurrentHashMap<>();
Image avatar(String userId, byte[] imageBytes) {
var key = new AvatarKey(userId, sha256(imageBytes));
return cache.computeIfAbsent(key, k -> resize(imageBytes));
}Hint: The same user uploads the same image twice. sha256 returns a new array with the same bytes each time. Are the two keys equal?
Records and data classes compare their components with equals, and for arrays equals means “the same array object”, not “the same contents”. Two sha256 calls return two different arrays, so two keys for the same image are never equal: every call misses, resizes again, and adds another entry. The cache grows forever and never helps. (Kotlin even warns about array properties in data classes; Java doesn’t.)
Use a component type with value equality:
data class AvatarKey(val userId: String, val fingerprintHex: String)
val key = AvatarKey(userId, sha256(imageBytes).toHexString())record AvatarKey(String userId, String fingerprintHex) {}
var key = new AvatarKey(userId, HexFormat.of().formatHex(sha256(imageBytes)));List<Byte> or ByteBuffer also compare by contents. Overriding equals/hashCode with Arrays.equals/Arrays.hashCode works too, but the array inside stays mutable, which is its own problem for a map key.
Before and after
- A Java 8 DTO was a class with private fields, a constructor, getters,
equals,hashCodeandtoString: about 50 lines for 4 fields. - A record is one line; add a compact constructor for validation.
- An
instanceofchain with casts (if (r instanceof Approved) { Approved a = (Approved) r; … }) becomes aswitchwith type patterns. - A
defaultbranch that throws “unknown type” disappears; the sealed interface makes it unnecessary and the compiler checks completeness.
What records are not
- Not deeply immutable: a
Listcomponent can be mutated by whoever holds it; copy in the compact constructor (items = List.copyOf(items)). See Immutability. - Not JPA entities: entities need a no-arg constructor, mutable state and identity semantics; records fit DTOs, projections (
SELECT new …, Spring Data interface/record projections) and value objects (@Embeddablerecords are supported since Hibernate 6.2). - Not extensible: records are final and can’t extend classes, which is the point.
- Jackson and records: supported out of the box (canonical constructor); Kotlin data classes need
jackson-module-kotlin.
- Records: final, shallowly immutable, component-based
equals/hashCode/toString; compact constructors validate/normalize; record patterns deconstruct. Arrays and mutable collections inside break value semantics (copy, or use value types). - Sealed interfaces + pattern
switch(Java 21) = exhaustive, compiler-checked dispatch; avoiddefaultso new subtypes break the build. - Use records for DTOs, projections, value objects, events; not as JPA entities.
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape
fun area(s: Shape) = when (s) {
is Circle -> Math.PI * s.r * s.r
is Rect -> s.w * s.h
}sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(var r) -> Math.PI * r * r;
case Rect(var w, var h) when w == h -> w * w; // guard
case Rect(var w, var h) -> w * h;
};
}Check this part
What does a Java record generate?
Why leave out
defaultin a switch over a sealed interface?A record has a byte[] component and is used as a HashMap key. What happens?