- Kotlin
Kotlin value classの使い方|IDの取り違えを型で防ぐ
value class は、値を1つだけ包む「軽い型」です。 UserId と OrderId のように、中身が同じ Long でも別の型として扱えるので、引数を取り違えるとコンパイルで止まります。実行時は多くの場面で中身の Long そのものになり、包むためのオブジェクトは作られません。
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class OrderId(val value: Long)
fun cancel(orderId: OrderId, userId: UserId) { /* … */ }
cancel(OrderId(500), UserId(10)) // OK
cancel(UserId(10), OrderId(500)) // コンパイルエラー
error: argument type mismatch: actual type is 'UserId', but 'OrderId' was expected.
error: argument type mismatch: actual type is 'OrderId', but 'UserId' was expected.
| 比べるもの | value class | data class | typealias |
|---|---|---|---|
| 取り違えをコンパイルで止める | 止まる | 止まる | 止まらない |
| 実行時のオブジェクト | 多くの場面で作らない | 毎回作る | 作らない(元の型) |
| init で値をチェック | できる | できる | できない |
| 持てるプロパティ | 主コンストラクタに1つだけ | 複数 | — |
| Java から呼ぶ | 一部の関数は名前が変わる(後述) | そのまま | そのまま |
確認環境:Kotlin コンパイラ kotlinc 2.4.20、OpenJDK 21.0.10(javac・javap)、Linux で、この記事のコードをコンパイル・実行し、生成されたクラスを javap で確かめました。value class の仕様は kotlinlang.org のドキュメントで、Jackson・Spring Data での扱いは各ドキュメントで確かめました(確認日 2026年10月12日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin value classの使い方|IDの取り違えを型で防ぐ」
value class の書き方
@JvmInline
value class Email(val value: String) {
init {
require("@" in value) { "invalid email: $value" }
}
val domain: String get() = value.substringAfter("@") // 計算するだけのプロパティは書ける
}
| 書けるもの | 書けないもの |
|---|---|
主コンストラクタの val 1つ |
2つ以上のプロパティ |
init ブロック・副コンストラクタ |
var のプロパティ |
関数、get() だけのプロパティ |
値を持つプロパティ(バッキングフィールド) |
interface の実装・委譲(by) |
クラスの継承(value class は常に final) |
==(中身で比べる) |
===(同じインスタンスか) |
JVM では @JvmInline が必須です。 書かないと次のエラーになりました。
error: value classes without '@JvmInline' annotation are not yet supported.
書けないものを書いたときのエラー文です(kotlinc 2.4.20)。
| 書いたもの | エラー文 |
|---|---|
value class Two(val a: Long, val b: Long) |
value class must have exactly one primary constructor parameter. |
value class VarProp(var a: Long) |
value class primary constructor must only have final read-only ('val') property parameters. |
本体に val b: Long = a * 2 |
value class cannot have properties with backing fields. |
value class Sub(val a: Long) : Base() |
value class cannot extend classes. |
UserId(1) === UserId(1) |
identity equality for arguments of types 'UserId' and 'UserId' is prohibited. |
=== が禁止されているのは、ドキュメントによると「value class は中身の値として表されることも、包まれた形で表されることもあるので、参照の等しさに意味がない」ためです。== は使えて、UserId(10) == UserId(10) は true でした。
ID の取り違えを型で防ぐ
ID を Long のまま渡すと、順番を入れ替えてもコンパイルは通ります。
// どちらも Long。入れ替えても気づけない
fun cancel(orderId: Long, userId: Long): String = "cancel $orderId by $userId"
cancel(10L, 500L) // 本当は (500, 10)
value class にすると、型が合わないものは渡せなくなります。
@JvmInline
value class UserId(val value: Long) {
init {
require(value > 0) { "UserId must be positive: $value" }
}
}
@JvmInline
value class OrderId(val value: Long)
class OrderService {
fun cancel(orderId: OrderId, userId: UserId): String =
"cancel ${orderId.value} by ${userId.value}"
fun findUser(id: UserId): String = "user ${id.value}"
}
| 書いたコード | 結果 |
|---|---|
svc.cancel(OrderId(500), UserId(10)) |
cancel 500 by 10 |
svc.cancel(UserId(10), OrderId(500)) |
argument type mismatch: actual type is 'UserId', but 'OrderId' was expected. |
svc.findUser(10L) |
argument type mismatch: actual type is 'Long', but 'UserId' was expected. |
val x: Long = UserId(1) |
initializer type mismatch: expected 'Long', actual 'UserId'. |
UserId(0) |
実行時に IllegalArgumentException: UserId must be positive: 0 |
typealias では止まりません。 typealias AliasUserId = Long と typealias AliasOrderId = Long を作って引数を入れ替えたコードは、エラーも警告も出ずにコンパイルが通りました。ドキュメントでも「typealias は元の型と代入互換だが、value class はそうではない」と説明されています。
init でのチェック
init の require で、不正な値の ID を作れなくできます。 生成されたクラスを見ると、UserId(0) は UserId.constructor-impl(long) という static メソッドの呼び出しになっていて、ここで init の中身が動きます。
ただし、後述の Java から @JvmName 付きの関数を呼ぶ経路では、このチェックを通りません。Java から findUserById(-1L) を呼ぶと、エラーにならず java -1 が返りました。
いつ箱に入るか(boxing を javap で確認)
value class は、使い方によって「中身の値のまま(unboxed)」か「UserId のオブジェクトに包まれた形(boxed)」になります。ドキュメントの目安は「別の型として使われるときに包まれる」です。次の関数をコンパイルして、javap でシグネチャと呼び出し側のコードを確かめました。
interface Identifier { val raw: Long }
@JvmInline
value class ProductId(override val raw: Long) : Identifier
class OrderService {
fun findUser(id: UserId): String = …
fun findUserOrNull(id: UserId?): String = …
fun findEmail(e: Email?): String = …
fun <T> generic(x: T): T = x
fun asInterface(i: Identifier): Long = i.raw
fun asAny(a: Any): String = a.toString()
fun many(ids: List<UserId>): Int = ids.size
}
$ javap -p demo.OrderService
public final java.lang.String findUser-4ZD5Yi0(long);
public final java.lang.String findUserOrNull-sDTk9kY(demo.UserId);
public final java.lang.String findEmail-SdAV0NQ(java.lang.String);
public final <T> T generic(T);
public final long asInterface(demo.Identifier);
public final java.lang.String asAny(java.lang.Object);
public final int many(java.util.List<demo.UserId>);
| 使い方 | 実行時の形 | 呼び出し側で box-impl |
|---|---|---|
UserId として渡す |
long |
なし |
UserId?(中身が Long) |
UserId(包まれる) |
あり |
Email?(中身が非 null の String) |
String(包まれない) |
なし |
ジェネリクス <T> に渡す |
Object(包まれる) |
あり |
interface(Identifier)として渡す |
包まれる | あり |
Any として渡す |
包まれる | あり |
List<UserId> の要素 |
包まれる | 要素ごとにあり |
== で比べる |
equals-impl0(long, long) |
なし |
get() のプロパティ(email.domain) |
getDomain-impl(String) の static 呼び出し |
なし |
呼び出し側(main)のバイトコードの一部です。
invokestatic demo/UserId."constructor-impl":(J)J
invokevirtual demo/OrderService."findUser-4ZD5Yi0":(J)Ljava/lang/String;
invokestatic demo/UserId."box-impl":(J)Ldemo/UserId;
invokevirtual demo/OrderService."findUserOrNull-sDTk9kY":(Ldemo/UserId;)Ljava/lang/String;
invokestatic demo/Email."constructor-impl":(Ljava/lang/String;)Ljava/lang/String;
invokevirtual demo/OrderService."findEmail-SdAV0NQ":(Ljava/lang/String;)Ljava/lang/String;
invokestatic demo/UserId."box-impl":(J)Ldemo/UserId;
invokevirtual demo/OrderService.generic:(Ljava/lang/Object;)Ljava/lang/Object;
nullable で包まれるかどうかは、中身の型で変わりました。 中身が Long(プリミティブ)の UserId? は、long では null を表せないので UserId に包まれます。中身が非 null の String の Email? は、String の null で表せるので包まれませんでした。
コレクションやジェネリクスでは、要素ごとにオブジェクトが作られます。 List<UserId> を大量に扱う処理では、value class でもオブジェクトの数は List<Long> を包んだ場合と同じくらいになります。「型で取り違えを防ぐ」ことが主な目的で、「必ずオブジェクトを作らない」わけではありません。
生成されるクラスの中身
$ javap -p demo.UserId
public final class demo.UserId {
private final long value;
public final long getValue();
public static java.lang.String toString-impl(long);
public static int hashCode-impl(long);
public static boolean equals-impl(long, java.lang.Object);
private demo.UserId(long);
public static long constructor-impl(long);
public static final demo.UserId box-impl(long);
public final long unbox-impl();
public static final boolean equals-impl0(long, long);
…
}
| メソッド | 役割 |
|---|---|
constructor-impl(long) |
init のチェックをして、中身の値を返す |
box-impl(long) |
包んだオブジェクトを作る |
unbox-impl() |
包んだオブジェクトから値を取り出す |
toString-impl などの -impl |
包まずに呼べる static 版 |
private UserId(long) |
コンストラクタは private。Java から new できない |
名前の変形(mangling)と Java から呼ぶとき
value class を引数に取る関数は、名前の後ろにハッシュが付きます。 findUser(id: UserId) は findUser-4ZD5Yi0(long) になりました。ドキュメントによると、findUser(id: Long) と findUser(id: UserId) が JVM では同じ形になって衝突するのを防ぐためです。- は Java の名前に使えないので、Java からは呼べません。
OrderService svc = new OrderService();
svc.findUser(1L); // error: cannot find symbol symbol: method findUser(long)
UserId u = new UserId(5L); // error: cannot find symbol symbol: constructor UserId(long)
| Java から呼びたいもの | 方法 | 生成された形 |
|---|---|---|
| value class を引数に取る関数 | @JvmName("findUserById") を付ける |
findUserById(long) |
| value class のコンストラクタ・包んだ形の関数 | @JvmExposeBoxed(実験的 API。@OptIn(ExperimentalStdlibApi::class) が要る) |
public MyInt(int)・timesTwo(MyInt) |
| モジュール全体 | コンパイラオプション -Xjvm-expose-boxed |
全宣言に @JvmExposeBoxed を付けたのと同じ(ドキュメント) |
class OrderService {
@JvmName("findUserById")
fun findUserJava(id: UserId): String = "java ${id.value}"
}
@file:OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
@JvmInline
value class MyInt(val value: Int)
@JvmExposeBoxed
fun MyInt.timesTwo(): MyInt = MyInt(value * 2)
MyInt input = new MyInt(5);
MyInt output = BoxedKt.timesTwo(input);
System.out.println(output); // MyInt(value=10)
@JvmExposeBoxed を付けると、javap で public demo2.MyInt(int) のコンストラクタと、timesTwo(demo2.MyInt)(包んだ形)が、元の timesTwo-A-l6c3I(int) と並んで生成されていました。Java のコードから上の形で呼べました。
@JvmName で long を受ける形にすると、Java からは型のチェックも init のチェックも効きません。 Java から使う入り口では、受け取った値をあらためて確かめます。
Kotlin と Java の書き方の違いは、関連記事「Kotlin と Java の違い 書き方対応表とコード比較30項目」で扱っています。
data class の中で使う
data class Order(val id: OrderId, val userId: UserId)
println(Order(OrderId(500), UserId(10)))
// Order(id=OrderId(value=500), userId=UserId(10))
$ javap -p demo.Order
private final long id;
private final long userId;
public final long getId-_nDkZZI();
public final long getUserId-krFoyOM();
public final demo.Order copy-vSC2uI0(long, long);
フィールドは long で、getter と copy の名前が変わっています。 Kotlin から使う分には意識しませんが、getter の名前(getUserId)やフィールドの型を手がかりにするライブラリでは、この形が影響します。toString() は、上書きしなければ OrderId(value=500) の形でした。data class の基本は、関連記事「Kotlin data classの使い方|copy・equalsとclass差」で扱っています。
data class・typealias・普通の class との違い
| 観点 | value class | data class | typealias | 普通の class |
|---|---|---|---|---|
| 新しい型になるか | なる | なる | ならない(別名) | なる |
| 持てる値 | 1つ | 複数 | — | 自由 |
| 実行時の形 | 多くは中身の値 | オブジェクト | 元の型 | オブジェクト |
equals・hashCode |
中身で自動 | 全プロパティで自動 | 元の型のまま | 自分で書く |
| 継承 | interface だけ | interface・open class | — | できる |
| Java からの見え方 | 名前が変わる関数がある | そのまま | 元の型 | そのまま |
選び方の目安です。
- ID・メールアドレス・金額など、値1つに意味を持たせたい → value class
- 値が2つ以上(緯度と経度など) → data class
- 長い型名を短く書きたいだけ(
Map<String, List<Int>>など) → typealias
Jackson・JPA・kotlinx.serialization との相性
| ライブラリ | 扱い | 出典・確かめ方 |
|---|---|---|
| kotlinx.serialization | @Serializable を付けると、中身の値だけで出力({"userId":42}) |
1.11.0 で実行して確認 |
| Jackson(jackson-module-kotlin) | 中身の値として扱う。読み込み(デシリアライズ)は 2.17 から対応 | モジュールの README・value class のページ |
| Spring Data(JPA 以外のモジュール) | value class のプロパティを読み書きできる | Spring Data Commons のドキュメント |
| JPA(Hibernate) | Spring Data のドキュメントでは、JPA は自前の対応づけを使うため上の節の対象外 | Spring Data Commons のドキュメント |
kotlinx.serialization
@Serializable
@JvmInline
value class UserId(val value: Long)
@Serializable
data class Order(val id: Long, val userId: UserId)
Json.encodeToString(Order(1, UserId(42))) // {"id":1,"userId":42}
{"userId":{"value":42}} にはならず、中身の値だけになりました。使い方は、関連記事「kotlinx.serialization使い方|JSON変換・設定一覧・エラー」で扱っています。
Jackson
jackson-module-kotlin のドキュメントでは、value class は「基本的に値として扱う」とされ、Value(1) は 1 に、同じ形の data class は {"value":1} になる、と説明されています。そのうえで「通常のクラス(data class など)との完全な互換は実現していない」こと、nullable な値を包む value class で境界のケースが多いこと、性能が下がることも書かれています。Jackson の基本は、関連記事「Jackson ObjectMapperの使い方|JSON変換・日付・record」で扱っています。
JPA(Hibernate)
上の javap の通り、value class のプロパティはフィールドが中身の型(long)になり、getter の名前が変わります。Spring Data のドキュメントでも「nullable なプリミティブの value class は包んだ型で表され、それがデータベースでの表し方に影響する」と注意されています。エンティティのフィールドは Long のままにして、リポジトリやサービスの境目で value class に変えると、JPA の対応づけに影響を出さずに型の安全を得られます。
@Entity
class OrderEntity(
@Id val id: Long,
val userId: Long,
) {
fun toDomain() = Order(OrderId(id), UserId(userId))
}
Spring Boot と Kotlin のエンティティの書き方は、関連記事「Spring Boot Kotlin|REST APIの完成コードとJPAの注意点」で扱っています。
よくある質問
Q. inline class と value class の違いは?
A. ドキュメントでは、この記事の型を「inline class」と呼び、「value-based class(値ベースのクラス)の一種」と説明しています。宣言には value 修飾子を使い、JVM では @JvmInline を付けます。以前の inline class Old(val a: Int) という書き方を kotlinc 2.4.20 でコンパイルすると、warning: 'inline' modifier is deprecated. Use 'value' instead. という警告が出ました。
Q. 速くなりますか。
A. UserId 型のまま渡す場面では、オブジェクトを作らないことを javap で確かめました。ただ、nullable(中身がプリミティブのとき)・ジェネリクス・interface・コレクションでは包まれます。性能を目的にするより、取り違えをコンパイルで止めることを主な目的にするのが合っています。
まとめ
@JvmInline value class UserId(val value: Long)で、Longと別の型になる。取り違えはargument type mismatchで止まった- typealias では止まらない(入れ替えてもコンパイルが通った)
initのrequireで不正な値を防げる。ただし Java の@JvmName経由ではチェックを通らない- 包まれるのは nullable(中身がプリミティブ)・ジェネリクス・interface・
Any・コレクション。Email?(中身 String)は包まれなかった - 引数に取る関数は 名前が
findUser-4ZD5Yi0のように変わる。Java からは@JvmNameか@JvmExposeBoxed - kotlinx.serialization と Jackson は中身の値で出力。JPA のエンティティは
Longのままにして境目で変えるのが目安
参考・出典
確認日はいずれも 2026年10月12日です。
- Kotlin ドキュメント「Inline value classes」:https://kotlinlang.org/docs/inline-classes.html
- Kotlin ドキュメント「Calling Kotlin from Java」(Inline value classes):https://kotlinlang.org/docs/java-to-kotlin-interop.html
- Kotlin API「JvmExposeBoxed」:https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.jvm/-jvm-expose-boxed/
- jackson-module-kotlin(GitHub):https://github.com/FasterXML/jackson-module-kotlin
- jackson-module-kotlin「value class support」:https://github.com/FasterXML/jackson-module-kotlin/blob/master/docs/value-class-support.md
- Spring Data Commons「Object Mapping Fundamentals」(Kotlin Value Classes):https://docs.spring.io/spring-data/commons/reference/object-mapping.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin value classの使い方|IDの取り違えを型で防ぐ」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。