mcpField
K
kotlin-types-value-class

kotlin-types-value-class

github.com/chrisbanesDevelopment1.1k844 installsUpdated 1d ago
Repository

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

  1. 01Decision guidance for single-field types
  2. 02Checks for equality and serialization changes
  3. 03Java interop and boxing considerations
  4. 04Compose stability review steps
  5. 05Refactor and performance checks

Use cases

  1. 01Reviewing primitive-heavy Kotlin APIs
  2. 02Evaluating a single-property data class
  3. 03Investigating Compose stability reports
  4. 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-class

After installing, type/kotlin-types-value-classin Claude Code to confirm it loaded.

Details

From the project README

Kotlin 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

  1. Find single-property wrappers, primitive-heavy APIs, and @Immutable wrappers in UI state.
  2. Decide whether the single value is a real domain distinction. If not, keep the primitive or use a typealias.
  3. Check whether replacing the type changes equality, serialization, Java interop, or hot-path boxing.
  4. Convert only when the domain meaning is clear and the contract changes are acceptable.
  5. Re-run the affected compiler/tests; for Compose performance work, re-check compiler reports or recomposition evidence.

Decision flow

SituationPrefer
Single field + domain-meaningful (UserId, EmailAddress, Percentage)@JvmInline value class
Single field + no domain meaning (just grouping)Type alias or keep the primitive
Multiple fieldsData class
Needs custom equals/hashCode beyond the wrapped valueData class (value classes delegate to the underlying type)
Used as a generic type argument or nullable in a proven hot pathData 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:

  1. Confirm the underlying type is stable (String, primitives, or another stable type).
  2. Prefer a value class over @Immutable on a wrapper whose only job is type distinction.
  3. 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:

CheckAction
JSON/API format mattersVerify 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 requiredKeep a data class. Value-class equality follows the wrapped value.
Callers use copy() or destructuringKeep a data class or update callers deliberately. Value classes do not provide data-class conveniences.
Java or reflection-heavy framework boundaryVerify interop. Java callers see the underlying type; generic/Any use boxes.
Nullable/generic/vararg hot pathMeasure before converting; those uses box.
Constructor body, lateinit, delegated properties, backing fieldsKeep 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

MistakeFix
Data class wrapping a single domain fieldReplace 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 equalityUse a data class instead
Value class as generic type argument in a hot pathMeasure boxing cost; keep the primitive/data class if it matters
@Immutable annotation on a type that could be a value classReplace with a value class when the underlying type is stable
Forgetting @JvmInline annotationAlways pair value class with @JvmInline for single-field classes

Red flags during review

  • A data class with exactly one property
  • A String, Long, or Int used where different values should not be interchangeable (e.g., fun transfer(from: String, to: String, amount: Long))
  • An @Immutable annotation 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

Comments

Markdown supported