kotlin-types-value-class

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Kotlin value class vs data class

Kotlin value class 与 data class 对比

Core principle

核心原则

Prefer
@JvmInline value class
for single-field types that carry domain meaning. Data classes are for aggregating multiple fields. A value class gives you type safety (you can't mix up
UserId
and
String
) without the allocation overhead of a data class.
对于具有业务领域含义的单字段类型,优先使用
@JvmInline value class
。data class适用于聚合多个字段的场景。value class能为你提供类型安全性(不会混淆
UserId
String
),同时避免data class带来的内存分配开销。

When to use this skill

适用场景

  • Writing a new Kotlin type that wraps a single value
  • Reviewing a data class that has only one property
  • Seeing primitive types (
    String
    ,
    Long
    ,
    Int
    , etc.) used where a domain type would prevent misuse
  • Compose compiler reports showing unstable parameters that could be value classes
  • 编写包装单一值的新Kotlin类型时
  • 审查仅包含一个属性的data class时
  • 发现原始类型(
    String
    Long
    Int
    等)被用于可通过领域类型避免误用的场景时
  • Compose编译器报告显示存在可改为value class的不稳定参数时

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
/
toString
beyond the wrapped value
Data class (value classes delegate to the underlying type)
Used as a generic type argument or nullable in hot pathsData class or primitive (autoboxing cost)
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 field
data class UserId(val value: String) // unnecessary allocation
data class EmailAddress(val value: String) // type safety without the overhead is available

// 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
场景优先选择
单字段且具有业务领域含义(如
UserId
EmailAddress
Percentage
@JvmInline value class
单字段但无业务领域含义(仅用于分组)类型别名(Type alias)或直接保留原始类型
多字段data class
需要在包装值之外自定义
equals
/
hashCode
/
toString
data class(value class的相关方法会委托给底层类型)
在性能敏感路径中用作泛型类型参数或可空类型data class或原始类型(避免自动装箱开销)
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 field
data class UserId(val value: String) // unnecessary allocation
data class EmailAddress(val value: String) // type safety without the overhead is available

// 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

Compose 稳定性

@JvmInline value class
is treated as
Stable
by the Compose compiler when its underlying type is stable (primitives,
String
, and other stable types). This means:
  • Value classes passed as composable parameters avoid "unstable parameter" warnings
  • No need for
    @Immutable
    annotations at Compose boundaries when wrapping primitives or strings
  • Replacing single-field data classes with value classes at UI boundaries improves skippability
kotlin
// Before: data class wrapping a single field
data class UiState(val userId: String) // works, but allocates a wrapper object

// After: value class is stable and zero-allocation at runtime
@JvmInline value class UserId(val value: String)
data class UiState(val userId: UserId)
@JvmInline value class
的底层类型为稳定类型(原始类型、
String
及其他稳定类型)时,会被Compose编译器视为
Stable
类型。这意味着:
  • 作为可组合函数参数传递的value class可避免「不稳定参数」警告
  • 包装原始类型或字符串时,无需在Compose边界添加
    @Immutable
    注解
  • 在UI边界将单字段data class替换为value class可提升可跳过性(skippability)
kotlin
// Before: data class wrapping a single field
data class UiState(val userId: String) // works, but allocates a wrapper object

// After: value class is stable and zero-allocation at runtime
@JvmInline value class UserId(val value: String)
data class UiState(val userId: UserId)

Gotchas

注意事项

  • Autoboxing: Value classes are unboxed at compile time but boxed (allocated) when used as nullable (
    UserId?
    ), generic type arguments (
    List<UserId>
    ), or vararg parameters. In hot paths these allocations matter; in most code they don't.
  • No backing fields: You cannot use
    init
    blocks,
    lateinit
    , or delegated properties like
    by lazy
    . The class body is extremely constrained — only the single constructor parameter exists.
  • No data-class conveniences: No
    copy()
    , no
    component1()
    for destructuring, and no way to customize
    toString()
    . If you need any of these, use a data class.
  • No custom equals/hashCode/toString: These always delegate to the underlying type. Need custom equality → use a data class.
  • when exhaustiveness: Sealed hierarchies of value classes work differently than data class hierarchies. Test
    when
    branches carefully.
  • Serialization semantics: With kotlinx.serialization, a
    @Serializable data class A(val value: String)
    serializes as
    {"value":"..."}
    , but a
    @Serializable value class A(val value: String)
    serializes as the underlying value (
    "..."
    ). Replacing a single-field data class with a value class is a breaking change for your API/JSON contract.
  • Serialization: Some serialization frameworks need explicit support for value classes (e.g., kotlinx.serialization's
    @Serializable
    works, but Jackson may need configuration).
  • Interoperability: From Java, value classes appear as their underlying type. Java callers bypass the type-safety wrapper.
  • Reflection and runtime erasure: When passed as
    Any
    or used in generic contexts, value classes box into a synthetic wrapper class. Java reflection sees mangled method signatures, and frameworks that rely on raw runtime types (some ORMs, DI containers, or serializers) may see the underlying type rather than the value class.
  • 自动装箱:value class在编译时是未装箱的,但用作可空类型(
    UserId?
    )、泛型类型参数(
    List<UserId>
    )或可变参数时会被装箱(分配内存)。在性能敏感路径中这些分配会产生影响,但在大多数代码中无需在意。
  • 无后备字段:无法使用
    init
    代码块、
    lateinit
    by lazy
    这类委托属性。类体受到严格限制——仅存在单个构造函数参数。
  • 无data class便捷特性:没有
    copy()
    方法,无法通过解构获取
    component1()
    ,也无法自定义
    toString()
    。如果需要这些特性,请使用data class。
  • 无法自定义equals/hashCode/toString:这些方法始终委托给底层类型。需要自定义相等逻辑→使用data class。
  • when表达式穷尽性:value class的密封层次结构与data class的层次结构表现不同。需仔细测试
    when
    分支。
  • 序列化语义:使用kotlinx.serialization时,
    @Serializable data class A(val value: String)
    会序列化为
    {"value":"..."}
    ,而
    @Serializable value class A(val value: String)
    会序列化为底层值(
    "..."
    )。将单字段data class替换为value class会导致API/JSON契约出现破坏性变更。
  • 序列化支持:部分序列化框架需要显式支持value class(例如kotlinx.serialization的
    @Serializable
    可正常工作,但Jackson可能需要额外配置)。
  • 互操作性:在Java中,value class会表现为其底层类型。Java调用者会绕过类型安全包装器。
  • 反射与运行时擦除:当作为
    Any
    类型传递或在泛型上下文中使用时,value class会装箱为一个合成包装类。Java反射会看到被混淆的方法签名,依赖原始运行时类型的框架(部分ORM、DI容器或序列化器)可能会识别到底层类型而非value class。

Packing multiple values

存储多个值

A value class can only declare one field, but Compose provides
packFloats
,
packInts
, and matching
unpack*
functions in
androidx.compose.ui.util
to store multiple primitives in a single
Long
. This lets you represent composite values (e.g., a 2D point, size, or padding) as a zero-allocation value class instead of a multi-field data class.
kotlin
@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)
  • Only use this in performance-critical paths — manual bit-packing is error-prone. A data class is simpler and safer for most UI types.
  • Available in
    androidx.compose.ui.util
    packFloats
    ,
    packInts
    ,
    unpackFloat1
    ,
    unpackFloat2
    ,
    unpackInt1
    ,
    unpackInt2
    .
value class只能声明一个字段,但Compose在
androidx.compose.ui.util
中提供了
packFloats
packInts
及对应的
unpack*
函数,可将多个原始类型存储到单个
Long
中。这让你可以将复合值(如二维坐标、尺寸或内边距)表示为零分配的value class,而非多字段的data class。
kotlin
@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)
  • 仅在性能敏感路径中使用——手动位打包容易出错。对于大多数UI类型,data class更简单安全。
  • 可在
    androidx.compose.ui.util
    中获取
    ——
    packFloats
    packInts
    unpackFloat1
    unpackFloat2
    unpackInt1
    unpackInt2

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 hot pathAccept autoboxing cost or use the primitive
@Immutable
annotation on a type that could be a value class
Replace with value class — it's Stable by default
Forgetting
@JvmInline
annotation
Always pair
value class
with
@JvmInline
for single-field classes
错误修复方案
使用data class包装单一业务领域字段替换为
@JvmInline value class
使用无业务领域含义的value class(仅作为包装器)使用类型别名或直接使用原始类型
使用需要自定义相等逻辑的value class改用data class
在性能敏感路径中将value class用作泛型类型参数接受自动装箱开销或改用原始类型
在可改为value class的类型上添加
@Immutable
注解
替换为value class——它默认是Stable类型
忘记添加
@JvmInline
注解
单字段类使用
value class
时必须搭配
@JvmInline

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)
  • 仅包含一个属性的data class
  • 在不应互换不同值的场景中使用
    String
    Long
    Int
    (例如
    fun transfer(from: String, to: String, amount: Long)
  • 在单字段包装类上添加
    @Immutable
    注解
  • 使用类型别名实现领域区分,但实际需要value class语义(类型别名会被类型擦除,无运行时保护)

When NOT to apply

不适用于以下场景

  • The type needs multiple fields → data class
  • The type needs custom
    equals
    /
    hashCode
    /
    toString
    → 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
  • 类型需要多个字段→使用data class
  • 类型需要自定义
    equals
    /
    hashCode
    /
    toString
    →使用data class
  • 类型在性能敏感代码中频繁用作可空类型或泛型→先评估自动装箱开销
  • 项目不需要类型安全区分→使用类型别名或原始类型即可

Related

相关链接

  • compose-stability-diagnostics
    — diagnose unstable Compose parameters; value classes are one fix
  • compose-stability-diagnostics
    —— 诊断Compose不稳定参数;value class是解决方案之一