skip to content

When modeling immutable value types, when do data class constraints push you toward a non-data class, a value class, or a @JvmRecord, and what constraints differ?

level: principalimportance: nice to knowfreq 15%

answer

  1. copy() leaks past factories → plain class for invariants
  2. @JvmInline value class = one val, no copy()
  3. @JvmRecord = real Java record, all val, no body fields
  4. All stay final; sealed interface for hierarchies
  5. Match the constraint to the requirement

basics

~20 s

Data classes can't be open/abstract/sealed/inner and always generate copy(), which leaks private state and boxes single fields. For invariants use a plain class plus factory; for one wrapped value use @JvmInline value class; for Java record interop use @JvmRecord on the data class.

solid answer

~50 s

Data class constraints — implicitly final, no `abstract`/`open`/`sealed`/`inner`, public `copy()` over all constructor properties — make them a poor fit in three situations. (1) Invariant-guarded types: `copy()` bypasses your validating factory, so use a regular `class` with a `private constructor` + companion factory instead. (2) A single-field wrapper where allocation matters: `@JvmInline value class` (formerly inline class) stores one `val` with no heap box on the happy path, but it forbids `var`, init blocks with side effects on the wrapped value differently, and has no `copy()`/`componentN()` in the same way. (3) Java interoperability where you want a real `java.lang.Record`: annotate the data class with `@JvmRecord`, which adds record-specific constraints (all params `val`, no extra backing-field body properties, no explicit `equals`/`hashCode` clashing with record accessors). Each path trades a different subset of the data class machinery.

code

kotlin · 10 lines
kotlin
// Invariant-guarded: no copy() escape hatch
class Email private constructor(val value: String) {
    companion object { fun of(s: String) = if ('@' in s) Email(s) else null }
}

// Type-safe single-value wrapper, low overhead
@JvmInline value class OrderId(val raw: Long)

// Real Java record for interop
@JvmRecord data class Point(val x: Int, val y: Int)

go deeper

for a junior

Knows a data class is the default value holder and that special cases exist.

for a middle

Can name value class and @JvmRecord and roughly when each applies.

for a senior

Maps each requirement (invariants, low overhead, record interop) to the right construct and its differing constraints.

for a principal

Sets team-wide guidance on modeling value types, weighing copy() leakage, boxing, interop, and hierarchy design via sealed interfaces.

## Why data class constraints force the choice A `data class` is final, may not be `abstract`/`open`/`sealed`/`inner`, requires ≥1 `val`/`var` constructor property, and **always** generates a public `copy()` covering every constructor property. These guarantees are great for transparent value holders but get in the way when you need (a) enforced invariants, (b) zero-overhead single-value wrapping, or (c) precise Java record interop. ## Option A — regular class + factory (for invariants) `copy()` is a back door: it reconstructs instances without going through any factory, so validation can be skipped. When a type must always be valid, drop `data`: ```kotlin class PositiveInt private constructor(val value: Int) { companion object { fun of(v: Int): PositiveInt? = if (v > 0) PositiveInt(v) else null } } ``` You lose generated `equals`/`hashCode`/`copy`/`componentN` and must write what you need by hand — but you gain a guarded constructor with no `copy()` escape hatch. ## Option B — @JvmInline value class (for single-field wrappers) A `@JvmInline value class` wraps exactly **one** `val` property and, on the JVM, avoids allocating a wrapper object in most cases (the underlying value is used directly): ```kotlin @JvmInline value class UserId(val raw: Long) ``` Constraints differ from data classes: - Exactly one property, and it must be `val` (no `var`). - No `inner`, no `init` that the wrapped value forbids depending on version; secondary state not allowed as backing fields. - It generates value-based `equals`/`hashCode`/`toString` but **not** `copy()`/`componentN()` like a data class. - It can implement interfaces but is boxed when used as a generic type, nullable, or interface receiver. Use it for type-safe IDs/units where you'd otherwise pass a bare `Long`/`String`. ## Option C — @JvmRecord (for Java record interop) Annotating a data class with `@JvmRecord` compiles it to a real `java.lang.Record` on JVM 16+: ```kotlin @JvmRecord data class Point(val x: Int, val y: Int) ``` Added constraints: - All primary-constructor parameters must be `val` (records are immutable). - No body properties with backing fields (records only have the component fields). - The class is implicitly final (already true for data classes) and gets record accessor methods (`x()`, `y()`). Use it when Kotlin types must look and behave like Java records for frameworks/serialization. ## Decision summary - Need enforced invariants / no copy() leak → plain class + private constructor + factory. - One wrapped value, want type safety + low overhead → `@JvmInline value class`. - Want a genuine Java record → `@JvmRecord data class`. - Otherwise, a plain `data class` is the right default for transparent value holders. ## Cross-cutting note None of these can be `open`/`abstract`; for a closed hierarchy of value types, use a `sealed interface` parent with final data/value-class leaves. The leaf type stays final precisely so its generated value semantics stay sound.

  • Why can't @JvmInline value class have a var property or two properties?
    A value class represents exactly one underlying value with no identity; it must be immutable (val) and single-field so the compiler can inline it and avoid allocation.
  • Does @JvmRecord remove the data class copy() function?
    No — a @JvmRecord data class still gets Kotlin's copy() and componentN(); @JvmRecord additionally makes it a java.lang.Record with record accessors and tightens the body-property rules.
  • How do you build a closed hierarchy of value types given data classes can't be sealed?
    Declare a sealed interface (or sealed class) as the parent and make each variant a separate final data class or value class implementing it.

Choosing among data class, value class, and @JvmRecord is like choosing packaging: a transparent box (data class), a shrink-wrap label on one item (value class), or a standardized shipping crate for the Java warehouse (@JvmRecord).

saying these in an interview costs you the question

  • Suggesting an open/abstract data class for a hierarchy
  • Claiming a value class supports var or multiple properties
  • Thinking @JvmRecord drops copy()/componentN()
  • Using a data class for a type that must enforce invariants
  • Believing value classes are never boxed at runtime

context