コンテンツにスキップ

value class と data class の違い

単一プロパティの値オブジェクトを Kotlin で実装するとき、@JvmInline value classdata class のどちらを使うか迷うことがあります。

どちらも「中身の値が同じなら同じもの」という値オブジェクトの性質は満たせます。違いは主に 実行時の表現言語機能の使いやすさ に現れます。

このページでは次の順で整理します。

  1. 比較一覧 … 各観点の違いとその理由
  2. 設計・実装の違い … それぞれが向く場面
  3. よくある誤解 … 型安全性についての注意点
  4. どちらを選ぶか … 判断の目安

コード例は次の記事を参照してください。


まず全体像を表にまとめます。続けて、なぜそうなるのか をひとつずつ説明します。

観点@JvmInline value classdata class
実行時のコスト多くの場面でラップせず、中身の型(例: UUID)として扱われる基本的には通常のオブジェクトとして扱われる
等価性ラップした値で比較コンストラクタの全プロパティで比較
プロパティ数1 つだけ1 つ以上。複数プロパティを自然に扱える
toString表示形式を明確にしたい場合は自分で定義するUserId(value=...) のように自動生成される
デバッガでの見え方中身の型に見えることがあるUserId として見えやすい
ライブラリ連携JSON シリアライズなどで追加設定が必要なことがあるフレームワークのサポートがわかりやすいことが多い

@JvmInline value class は、コンパイル時には UserId という独立した型ですが、JVM 上では 中身の UUID にできるだけ寄せて表現 されます。
そのため、引数の受け渡しなどでは余分なラッパーオブジェクトを作らずに済むことが多いです。

@JvmInline
value class UserId(val value: UUID)
fun findUser(id: UserId) { ... }

UserId は「UUID を誤って渡すとコンパイルエラーにするための型」として機能し、余分なオブジェクトを作らない ことが多いです。

data class は通常のクラスと同様、基本的には UserId インスタンスがオブジェクトとして扱われます
1 件あたりの差は小さく見えますが、ID のように大量に扱う値では、メモリや GC への影響が積み重なることがあります。

data class UserId(val value: UUID)

呼び出し側のコードは同じ形に書けますが、メモリや GC の観点では value class のほうが軽い ことが多いです。

値オブジェクトに必要なのは「中身が同じなら同じものとして扱う」ことです。どちらもこの要件は満たせます。

  • value class … ラップした値(例: UUID)が同じなら等しいと判定される
  • data class … コンストラクタの全プロパティが同じなら等しいと判定される

単一プロパティであれば結果はほぼ同じですが、仕組みが異なります。
data class はプロパティが増えたときも、自動生成された equals / hashCode がそのまま使えるのが利点です。

value class単一のプロパティだけ を持てます。
そのため、UserIdEmail のように、1 つの値を型で包みたい場合に向いています。

@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "メールアドレスの形式が不正です" }
}
}

data class は複数のプロパティを持てるため、AddressMoney のように、複数の属性をまとめて 1 つの概念として扱いたい場合に向いています。
値そのものの制約だけでなく、プロパティ間の整合性も init で検証できます。

enum class Currency {
JPY, USD
}
data class Money(
val amount: Long,
val currency: Currency,
) {
init {
require(amount >= 0) { "金額は0以上である必要があります" }
}
}

検証に init を使えるかどうかは、両者の大きな違いではありません。
判断の中心は、表したい値が 単一の値 なのか、複数の属性の組み合わせ なのかです。

data classtoString が自動生成され、UserId(value=550e8400-e29b-...) のように 型名とプロパティ名付き で表示されます。ログやデバッグで「何の値か」がわかりやすいです。

value class はコンパイル後の表現が中身の型に近くなるため、ログ・デバッガ・ライブラリ経由では見え方が環境に左右されることがあります。
表示形式を明確にしたい場合は、自分で toString を定義します。

value class は JVM 上で中身の型に近い形で表現されるため、デバッガ上では UUID として表示される ことがあります。
コンパイル時の型安全性は保たれますが、実行時の見た目は中身の型に近くなります。

data class は通常のオブジェクトとして扱われるため、変数の型名 UserId として表示されやすい です。
チームの好みや、デバッグのしやすさによっては、この差が選定理由になることもあります。

Jackson や kotlinx.serialization など、多くのライブラリは 通常のクラスや data class を前提 に設計されています。
data class なら、アノテーションを付けるなど比較的素直に連携できることが多いです。

value class はコンパイル後の表現が中身の型に近くなるため、シリアライズ時に追加の設定やカスタムコンバータが必要になる ことがあります。
API の入出力や ORM との接続を早い段階で組むプロジェクトでは、data class のほうが手戻りが少ない場合があります。


比較一覧で見た特性を踏まえると、次のような場面で使い分けることになります。

  • 識別子(UserId, OrderId)のように 型を分けたいだけ の単純なラップ
  • パフォーマンスやメモリを気にしたい
  • 単一の値に対する制約を小さく閉じ込めたい
  • 複数プロパティをまとめて扱い、プロパティ間の整合性 も検証したい
  • シリアライズ・ORM・テストツールなど、オブジェクトとして扱うライブラリ との相性を優先したい
  • チーム内で data class のほうが読みやすい・慣れている

比較一覧の「実行時のコスト」で触れた「実行時に中身の型として扱われる」という説明を見ると、UserIdUUIDOrderId と互換性があるのでは? と感じることがあります。これは誤解です。

UserIdUUIDOrderId の代わりに渡せるわけではない

Section titled “UserId を UUID や OrderId の代わりに渡せるわけではない”

Kotlin のコード上では、UserIdUUIDOrderId の代わりにそのまま渡すことはできません。

「実行時に中身の型として扱われる」は、メモリ上の表現の話 であり、型の互換性の話ではありません。
コンパイル時には UserIdOrderIdUUID はそれぞれ別の型として扱われます。

import java.util.UUID
@JvmInline
value class UserId(val value: UUID) {
companion object {
fun generate(): UserId = UserId(UUID.randomUUID())
}
}
@JvmInline
value class OrderId(val value: UUID) {
companion object {
fun of(value: UUID): OrderId = OrderId(value)
}
}
fun findUser(id: UserId) { ... }
fun findOrder(id: OrderId) { ... }
fun saveRaw(id: UUID) { ... }
val userId = UserId.generate()
findUser(userId) // OK
findOrder(userId) // コンパイルエラー(OrderId が必要)
saveRaw(userId) // コンパイルエラー(UUID が必要)
saveRaw(userId.value) // OK(明示的に取り出した場合のみ)

value class の目的は、UUID としてどこでも使えるようにすることではなく、素の UUID より取り違えにくくすることです。
data class でも同様に、暗黙の型変換は起きません。

本当の注意点は .value で型を剥がすこと

Section titled “本当の注意点は .value で型を剥がすこと”

コンパイラが防げないのは、.value を経由して別の ID 型を作る ケースです。

val userId = UserId.generate()
val orderId = OrderId.of(userId.value) // コンパイルは通るが、意味的には危険

このような取り違えを防ぐには、次のような工夫が有効です。

  • .value は DB 層やシリアライズ層など、ドメインの外側 でだけ使う
  • 可能なら valueprivate にし、変換はファクトリやリポジトリに閉じる
  • ドメイン層の関数は UUID ではなく UserId / OrderId を引数に取る

比較一覧と設計・実装の違いを踏まえた判断の目安を、表にまとめます。

状況おすすめ
UUID などの識別子を型安全にラップしたいまず @JvmInline value class
複数プロパティの整合性を検証したいdata class
ライブラリ連携でつまずきやすいdata class を検討
呼び出し側の API を揃えたいどちらでも可(ファクトリを共通化すれば同じにできる)

実際のプロジェクトでは どちらか一方を選んで統一 するのが一般的です。
識別子のように単純なラップが中心なら @JvmInline value class、複数プロパティの表現やツール連携を優先するなら data class、という判断で十分なことが多いです。

具体的な書き方は次の記事を参照してください。