About kotlin-types-value-class
A review skill for choosing between Kotlin value classes, data classes, type aliases, and primitives. It guides domain modeling while accounting for serialization, Java interop, Compose stability, and boxing costs.
Key features
- 01Decision guidance for single-field types
- 02Checks for equality and serialization changes
- 03Java interop and boxing considerations
- 04Compose stability review steps
- 05Refactor and performance checks
Use cases
- 01Reviewing primitive-heavy Kotlin APIs
- 02Evaluating a single-property data class
- 03Investigating Compose stability reports
- 04Assessing a value-class change at API boundaries
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ chrisbanes/ skills --skill kotlin-types-value-classAfter installing, type/kotlin-types-value-classin Claude Code to confirm it loaded.
Details
From the project READMEKotlin value class vs data class
Core principle
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
@Immutablewrappers 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 |
// 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 field
data 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
@Immutableon a wrapper whose only job is type distinction. - Do not change public serialization/API contracts just to silence a report.
// Before: primitive value can be mixed up with other strings
data 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. |
| Nullable/generic/vararg hot path | Measure before converting; those uses box. |
Constructor body, lateinit, delegated properties, backing fields | 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:
@JvmInline value class Offset(val packedValue: Long)
fun Offset(x: Float, y: Float): Offset = Offset(packFloats(x, y))
val Offset.x: Float get() = unpackFloat1(packedValue)
val Offset.y: Float get() = unpackFloat2(packedValue)
Common mistakes
| Mistake | Fix |
|---|---|
| Data class wrapping a single domain field | Replace with @JvmInline value class |
| Value class with no domain meaning (just a wrapper) | Use a type alias or the primitive directly |
| Value class needing custom equality | Use a data class instead |
| Value class as generic type argument in a hot path | Measure boxing cost; keep the primitive/data class if it matters |
@Immutable annotation on a type that could be a value class | Replace with a value class when the underlying type is stable |
Forgetting @JvmInline annotation | Always pair value class with @JvmInline for single-field classes |
Red flags during review
- A data class with exactly one property
- A
String,Long, orIntused where different values should not be interchangeable (e.g.,fun transfer(from: String, to: String, amount: Long)) - An
@Immutableannotation on a single-field wrapper - A type alias used for domain distinction where value-class semantics are needed (type aliases are type-erased, no runtime protection)
When NOT to apply
- The type needs multiple fields → data class
- The type needs custom
equals/hashCode→ data class - The type is used heavily as a nullable or generic in performance-critical code → measure autoboxing cost first
- The project does not need the type-safety distinction → a type alias or primitive is sufficient
- The replacement would silently change JSON, Java, reflection, or framework behavior
Related
compose-stability-diagnostics— diagnose unstable Compose parameters; value classes are one fix