KotlinのSealed classとwhen

1. 概要

値が限定されたいくつかのケースにしか落ち込まない場合、例えば支払い結果が「成功」「失敗」「保留中」のいずれかである場合、コンパイラにその一覧を知らせたいでしょう。Kotlinはsealed classとsealed interfaceを使って継承階層をロックダウンします。when式は、すべてのブランチを処理したかどうかを検証できます。

この記事を読み終えたら、sealed階層の宣言方法とexhaustiveなwhenを書く方法がわかります。また、guard条件の使い方、このロジックをテストする方法、そしてsealed classを使うべきでない場合を認識できるようになります。

2. 開放的な継承階層の問題

通常のabstract classを使って支払い結果をモデル化しようとしてみます:

abstract class PaymentResult

class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()

fun describe(result: PaymentResult): String = when (result) {
    is Success -> "OK: ${result.transactionId}"
    is Failed -> "エラー: ${result.reason}"
    else -> "不明"
}

elseブランチは必須です。理由は、誰でも、どのモジュールからでも、PaymentResultの新しいサブクラスを作成できるからです。コンパイラはサブクラスのリストが完全であるかどうかを知ることができません。

結果として、誰かがclass Refunded : PaymentResult()を追加しても、コードは問題なくコンパイルされます。Refunded値は静かに"不明"ブランチに落ちます。このタイプのエラーは通常、本番環境でのみ明らかになります。

enum classは一部を解決します。なぜなら定数のリストは固定だからです。しかし、各enum定数は一つのインスタンスに限定されます。異なるtransactionIdを異なる支払い成功に割り当てることはできません。

3. Sealed classの宣言

Sealed classは両方のアプローチの利点を結合します。サブクラスのリストはenumのように固定されていますが、各サブクラスは通常のクラスのように独自のデータを持つことができます。

3.1. 依存関係

この記事の例はKotlin 2.2 on JVM 21を使用します。テスト部分はJUnit 5とAssertJを使用します:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.2.20</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.27.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

また、Kotlinのソースコードをコンパイルするために、同じバージョンのkotlin-maven-pluginを設定する必要があります。

3.2. シンプルなsealed階層

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
}

上記のコードの各部分の意味は以下の通りです:

  • sealed class PaymentResultは暗黙的なabstract classです。PaymentResult()を直接呼び出すことはできません。そのコンストラクタはデフォルトでprotectedです。
  • SuccessとFailedはdata classです。equals、hashCode、toString、copyが自動生成され、テスト時に非常に便利です。
  • Pendingはデータを持たないため、data objectとして使用します。これはシングルトンであり、toString()がPaymentResult$Pending@1b6d3586のようなハッシュコードではなくPendingを出力します。

最も重要なルールは:すべての直接的なサブクラスは、sealed classと同じパッケージとモジュール内にある必要があります。異なるファイルに分割することはできますが、同じパッケージ内である必要があります。ただし、あなたのライブラリを使用する別のモジュールからは、新しいサブクラスを追加することはできません。このルールのおかげで、コンパイラはサブクラスのリストを確実に知ることができるのです。

4. Exhaustiveなwhen式

4.1. elseブランチを削除する

whenの検査対象がsealed型である場合、elseは不要になります:

fun describe(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "支払い完了 ${result.amount} 円(ID: ${result.transactionId})"
    is PaymentResult.Failed -> "失敗: ${result.reason}"
    PaymentResult.Pending -> "処理中"
}

fun main() {
    println(describe(PaymentResult.Success("TX-1001", 250_000)))
    println(describe(PaymentResult.Failed("カード期限切れ", retryable = false)))
    println(describe(PaymentResult.Pending))
}

実行結果:

支払い完了 250000 円(ID: TX-1001)
失敗: カード期限切れ
処理中

上記のコードで注目すべき3つのポイントがあります:

  • Smart cast: is PaymentResult.Successブランチ内では、変数resultは自動的にSuccessにキャストされます。したがって、手動キャストなしでresult.amountにアクセスできます。
  • objectの場合は、isを使わずにPaymentResult.Pendingで直接比較します。is PaymentResult.Pendingと書いても正しいですが、不要です。
  • ここのwhenは式です。つまり値を返し、関数の本体として割り当てられます。when式はexhaustiveである必要があります。

4.2. 新しいケースを追加するとコンパイラがエラーを検出する

返金状態を追加してみましょう:

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
    data class Refunded(val transactionId: String) : PaymentResult()
}

関数describeは即座にコンパイルできなくなります:

e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.

これはsealed classの核となる価値です。PaymentResultに対してwhenを使用するコード内のすべての場所で、コンパイラはあなたを指摘します。忘れられる場所はありません。

4.3. ステートメント形式のwhen

Kotlin 1.7以降、whenがステートメントとして使用される場合(戻り値を取得しない)でも、subjectがsealed型、enum、またはBooleanである場合、コンパイラはブランチが不足していたらエラーを報告します:

fun log(result: PaymentResult) {
    when (result) {
        is PaymentResult.Success -> println("INFO トランザクション ${result.transactionId} 完了")
        is PaymentResult.Failed -> println("WARN ${result.reason}")
        PaymentResult.Pending -> println("DEBUG 処理中")
        is PaymentResult.Refunded -> println("INFO トランザクション ${result.transactionId} 返金")
    }
}

Refundedブランチを省略すると、コンパイラはwarningではなくエラーを報告します。

5. Sealed interface

クラスはサブクラスを1つだけ継承できますが、複数のinterfaceを実装できます。したがってsealed interfaceは、あるタイプが複数のカテゴリに同時に属する場合、より柔軟です:

sealed interface OrderEvent
sealed interface Notifiable {
    val customerEmail: String
}

data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent

fun handle(event: OrderEvent): String = when (event) {
    is OrderPlaced -> "注文 ${event.orderId} の請求書を作成"
    is OrderShipped -> "追跡コード ${event.trackingCode}"
    is StockReserved -> "SKU ${event.sku} を確保"
}

fun emailOf(target: Notifiable): String = when (target) {
    is OrderPlaced -> "注文確認を ${target.customerEmail} に送信"
    is OrderShipped -> "配送通知を ${target.customerEmail} に送信"
}

OrderPlacedはOrderEventでありながらNotifiableでもあります。関数emailOfはブランチが2つだけで済みます。コンパイラはNotifiableがちょうど2つのクラスによって実装されていることを知っているからです。

Sealed interfaceはenumとも相性が良いです。enum classはsealed interfaceを実装でき、各enum定数はwhen内で1つのブランチとしてカウントされます。

5.1. 複数レベルのネスト

サブクラス自身もsealedにできます。こうして、より詳細にグループ化できます:

sealed interface PaymentError {
    sealed interface Retryable : PaymentError
    data object Timeout : Retryable
    data object GatewayBusy : Retryable
    data class CardDeclined(val code: String) : PaymentError
}

fun shouldRetry(error: PaymentError): Boolean = when (error) {
    is PaymentError.Retryable -> true
    is PaymentError.CardDeclined -> false
}

ブランチis PaymentError.RetryableはTimeoutとGatewayBusyの両方をカバーするので、2つのブランチだけでもwhenはexhaustiveです。

6. Whenのguard条件

同じ型だが、内側のデータに応じて異なる処理をしたい場合があります。Kotlin 2.2はguard条件(ブランチ条件の後のifキーワード)を正式にサポートしています:

fun nextAction(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "請求書を送信"
    is PaymentResult.Failed if result.retryable -> "30秒後に再試行"
    is PaymentResult.Failed -> "エラーを顧客に報告"
    PaymentResult.Pending -> "決済ゲートウェイからのwebhookを待機"
    is PaymentResult.Refunded -> "会計帳簿を更新"
}

Guard条件のあるブランチは、型とif条件の両方が満たされてくる場合のみマッチします。コンパイラはguard付きのブランチをFailedをカバーしているとは見なしません。したがって、guard条件のないis PaymentResult.Failedブランチを省略すると、exhaustive errorが発生します。

注意: Kotlin 2.1では、guard conditionはプレビュー機能であり、-Xwhen-guardsフラグが必要です。プロジェクトのKotlinバージョンを確認してから使用してください。

7. サービス内での組み合わせ

実際のコードでは、sealed classは通常、exceptionを投げる代わりにサービスの戻り値の型として使用されます:

data class Order(val id: String, val amount: Long)

class PaymentService {
    fun charge(order: Order): PaymentResult = when {
        order.amount <= 0 -> PaymentResult.Failed("金額が無効", retryable = false)
        order.amount > 50_000_000 -> PaymentResult.Pending
        else -> PaymentResult.Success("TX-${order.id}", order.amount)
    }
}

fun main() {
    val service = PaymentService()
    when (val result = service.charge(Order("A42", 120_000))) {
        is PaymentResult.Success -> println("トランザクションID: ${result.transactionId}")
        else -> println(describe(result))
    }
}
トランザクションID: TX-A42

関数charge内のwhenはsubjectを持たないため、elseが必要です。ここではelseは完全に合理的です。条件は任意のboolean式だからです。

main内のwhen (val result = ...)構文は、サービスを呼び出しながら変数resultのスコープをwhenブロック内に制限します。ここのelseは意図的です。成功ケースだけに関心があります。セクション9はこの選択の代償について詳しく説明します。

8. テスト記述

data classのおかげで、結果をisEqualToで簡潔に比較できます:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PaymentServiceTest {

    private val service = PaymentService()

    @Test
    fun `有効な注文はSuccessを返す`() {
        val result = service.charge(Order("A1", 99_000))

        assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
    }

    @Test
    fun `負の金額はFailedを返す(再試行不可)`() {
        val result = service.charge(Order("A2", -5))

        assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
        assertThat((result as PaymentResult.Failed).retryable).isFalse()
    }

    @Test
    fun `大きな注文はPendingに変わる`() {
        assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
    }

    @Test
    fun `再試行可能なエラーは再試行アクションにつながる`() {
        val action = nextAction(PaymentResult.Failed("ゲートウェイタイムアウト", retryable = true))

        assertThat(action).isEqualTo("30秒後に再試行")
    }
}

このテストスイートは3つのポイントをチェックしています:

  • 最初のテストはdata classによって生成されたequalsに依存して、すべてのデータを比較します。
  • isSameAsはdata objectに使用します。Pendingはシングルトンなので、参照比較で十分です。
  • 最後のテストはguard条件のあるブランチをテストしています。

9. 一般的なエラーと使用すべきでない場合

Sealed型のwhenにelseを追加する。 これは最も一般的なエラーです。無用なelseブランチはexhaustiveチェックメカニズムを完全に無効にしてしまいます。新しいサブクラスが追加されてもコンパイラは黙ったままです。本当に残りのケースすべてをグループ化したい場合のみelseを使用してください(セクション7の例のように)。

Subjectのないwhenを使用する。 when { result is Success -> ... }はsealed階層に基づいてexhaustiveチェックされません。when (result)と書いてコンパイラが分析できるようにしてください。

objectにdataを付け忘れる。 通常のobject Pendingでもコンパイルと実行はできます。しかし、ログにはクラス名とハッシュコードが出力され、デバッグ時に読みにくくなります。

モジュール外にサブクラスを配置する。 ライブラリを書いていて、ユーザーが独自に拡張できるようにしたい場合(プラグインなど)、sealed classは間違った選択です。通常のinterfaceまたはabstract classを使用してください。

注意: 以下の場合はsealed classを使用すべきではありません:

  • ケースがLOW、MEDIUM、HIGHのようなデータを持たない定数のみの場合。このときはenum classがコンパクトで、entriesやvalueOfがあります。
  • サブクラスのリストが頻繁に変わり、複数のチームが複数のモジュールから貢献する場合。
  • 振る舞いはオブジェクト自体に住むべき場合(method overrideでのポリモーフィズム)。同じwhenが10か所に現れるなら、各サブクラスのメソッドにロジックを移すことを検討してください。

10. 結論

Sealed classとsealed interfaceは、継承階層を1つのモジュール内にロックダウンさせます。おかげでコンパイラはすべてのケースを知ることができます。Subjectのあるwhenと組み合わせることで、smart castが得られ、else不要になり、新しい状態を処理し忘れたときにコンパイルエラーが得られます。

データを持つケースにはdata classを、データなしのケースにはdata objectを使用してください。複数のグループに属する必要がある場合はsealed interfaceを選択してください。Kotlin 2.2のguard conditionは、内側のデータに基づいてブランチを分割するのに役立ちます。最も重要なのは、不要なelseブランチを避けることです。それがあなたが求めていた利点そのものを失わせてしまうからです。

kotlinsealed classwhenパターンマッチング型安全性