Prefer @JvmInline value class for single-field types that carry domain meaning. Data classes are for aggregating multiple fields.
Review procedure
Find single-property wrappers, primitive-heavy APIs, and @Immutable wrappers in UI state.
Decide whether the single value is a real domain distinction. If not, keep the primitive or use a typealias.
Check whether replacing the type changes equality, serialization, Java interop, or hot-path boxing.
Convert only when the domain meaning is clear and the contract changes are acceptable.
Re-run the affected compiler/tests; for Compose performance work, re-check compiler reports or recomposition evidence.
Decision flow
Situation
Prefer
Single field + domain-meaningful (UserId, EmailAddress, Percentage)
@JvmInline value class
Single field + no domain meaning (just grouping)
Type alias or keep the primitive
Multiple fields
Data class
Needs custom equals/hashCode beyond the wrapped value
Data class (value classes delegate to the underlying type)
Used as a generic type argument or nullable in a proven hot path
Data class or primitive
kotlin
// GOOD: domain-meaningful single field@JvmInline value class UserId(val value: String)@JvmInline value class EmailAddress(val value: String)@JvmInline value class Percentage(val value: Float)// BAD: data class wrapping a single domain fielddata class UserId(val value: String)// BAD: value class with no domain meaning@JvmInline value class Wrapper(val value: String) // just use the String, or a type alias// BAD: value class needing custom equality@JvmInline value class CaseInsensitiveString(val value: String)// value class equals delegates to String equals, which IS case-sensitive// Use a data class if you need different equality semantics
Compose stability procedure
When a Compose report points at a single-field wrapper:
Confirm the underlying type is stable (String, primitives, or another stable type).
Prefer a value class over @Immutable on a wrapper whose only job is type distinction.
Do not change public serialization/API contracts just to silence a report.
kotlin
// Before: primitive value can be mixed up with other stringsdata class UiState(val userId: String)// After: domain type is stable at the Compose boundary@JvmInline value class UserId(val value: String)data class UiState(val userId: UserId)
Refactor checks
Before replacing an existing wrapper, check the contract that callers observe:
Check
Action
JSON/API format matters
Verify serialization. @Serializable data class A(val value: String) encodes as an object; a value class encodes as the wrapped value.
Custom equality or hashing is required
Keep a data class. Value-class equality follows the wrapped value.
Callers use copy() or destructuring
Keep a data class or update callers deliberately. Value classes do not provide data-class conveniences.
Java or reflection-heavy framework boundary
Verify interop. Java callers see the underlying type; generic/Any use boxes.
Keep a data class or redesign; value classes only store the constructor value.
Packing multiple values only after evidence
Do not replace a clear multi-field data class with bit-packing unless profiling shows allocation cost on a hot path. If needed, Compose provides packFloats, packInts, and matching unpack* functions in androidx.compose.ui.util: