📑

Result型とsealed interfaceで型安全なエラーハンドリングを実現する

に公開

こんにちは、今年の4月に新卒でログラスにエンジニアとして入社したDavidです。
今回は、Kotlinのエラーハンドリングをより安全に扱うために学んだことを、簡単に共有したいと思います!

はじめに

Kotlinでエラー処理を書くとき、こんなコードを書いていませんか?

fun createUser(email: String, password: String): User {
    if (email.isBlank()) throw IllegalArgumentException("メールアドレスが空です")
    if (password.length < 8) throw IllegalArgumentException("パスワードが短すぎます")
    return User(email, password)
}

実はこのコードには、3つの落とし穴があります。分かりますか?

このコードを呼び出す側は、createUserが例外を投げることに気づけるでしょうか?try-catchを書き忘れたらどうなるでしょうか?エラーの種類によって処理を分けたい場合は?

この記事では、throwを使ったエラー処理に潜む落とし穴と、Result型とsealed interfaceを使った型安全な解決策を紹介します。

この記事で学べること

  • throwの落とし穴
  • Result型とは何か、どう使うのか
  • sealed interfaceでエラーを型として表現する方法
  • Result型の処理メソッド

対象読者

  • Kotlinの基本文法を理解している方
  • エラー処理をより良くしたいと考えている方
  • 関数型プログラミングに興味がある方

1. 例外処理の落とし穴

そもそも例外(Exception)とは?

プログラムが正常に動作できない状況が発生したとき、「例外」を投げる(throwする)ことでエラーを通知できます。

// 例外を投げる例
fun createUser(email: String, password: String): User {
    if (email.isBlank()) throw IllegalArgumentException("メールアドレスが空です")
    if (password.length < 8) throw IllegalArgumentException("パスワードが短すぎます")
    return User(email, password)
}

// 例外をキャッチする例
fun main() {
    try {
        val user = createUser("", "123")
        println("登録完了: ${user.email}")
    } catch (e: IllegalArgumentException) {
        println("エラー: ${e.message}")
    }
}

この仕組みは直感的で分かりやすいのですが、実は問題を抱えています。

3つの落とし穴

落とし穴1: 関数のシグネチャから例外が見えない

fun createUser(email: String, password: String): User

この関数定義を見て、例外が投げられる可能性に気づけますか?

Javaには「検査例外(Checked Exception)」があり、throwsキーワードで例外を宣言する必要がありました。しかしKotlinには検査例外がありません。そのため、実装を読むかドキュメントを確認しない限り、呼び出し側は例外の存在に気づけません。

落とし穴2: catchを忘れてもコンパイルが通る

fun main() {
    val user = createUser("invalid-email", "123")  // 実行時にクラッシュ!
    println("登録完了: ${user.email}")
}

コンパイラは何も警告してくれません。本番環境で初めて問題が発覚することもあります。

落とし穴3: エラーの種類で処理を分けにくい

fun createUser(email: String, password: String): User {
    if (email.isBlank()) throw IllegalArgumentException("メールアドレスが空です")
    if (password.length < 8) throw IllegalArgumentException("パスワードが短すぎます")
    return User(email, password)
}

2つのエラーが全てIllegalArgumentException。呼び出し側でエラーの種類を判別するには、メッセージ文字列を比較するしかありません。

理想的なエラーハンドリングとは

理想的なエラーハンドリングには、以下の特性が必要です。

特性 説明
明示性 関数がエラーを返す可能性があることが型から分かる
網羅性 全てのエラーケースを処理したことをコンパイラが保証
型安全性 エラーの種類ごとに異なる処理ができる
情報保持 エラーに関連する情報(どのデータが問題だったか等)を保持

これを実現するのが、Result型とsealed interfaceの組み合わせです。

2. Result型の基本

Result型とは?

Result型は、処理の結果が「成功」か「失敗」かを表す型です。もともとRustで採用されている概念で、関数型プログラミングの影響を受けた設計です。例外を使わずにエラーを型として扱うアプローチとして広まりました。

日常生活で例えると、お店に買い物に行ったとき

  • 成功(Ok): 商品を買えた → 商品を持って帰る
  • 失敗(Err): 商品が売り切れだった → 「売り切れ」という情報を持って帰る

プログラムでも同じです。

// 成功の場合: Userを返す
// 失敗の場合: エラー情報(文字列)を返す
fun createUser(email: String, password: String): Result<User, String>

Result<User, String>は「成功したらUserを、失敗したらString(エラーメッセージ)を返す」という意味です。

kotlin-resultライブラリの導入

この記事では、kotlin-resultというライブラリを使用します。

// build.gradle.kts
dependencies {
    implementation("com.michael-bull.kotlin-result:kotlin-result:2.1.0")
}

補足: なぜライブラリを使うの?

Kotlin標準ライブラリにもResult型がありますが、エラー型がThrowableに限定されています。kotlin-resultライブラリを使うと、任意の型をエラーとして使えるため、より柔軟なエラーハンドリングが可能になります。

kotlin-resultを詳しく知りたい方は以下の記事を参考にしてください。

https://zenn.dev/loglass/articles/try-using-kotlin-result

Result型の基本的な使い方

import com.github.michaelbull.result.Result
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Err

// 成功を表す: Ok(値)
val success: Result<Int, String> = Ok(42)

// 失敗を表す: Err(エラー)
val failure: Result<Int, String> = Err("エラーが発生しました")

Before/After: 例外からResult型へ

実際のコードで比較してみましょう。

Before: 例外を使った実装

// 例外ベース
fun createUser(email: String, password: String): User {
    if (email.isBlank()) throw IllegalArgumentException("メールアドレスが空です")
    if (password.length < 8) throw IllegalArgumentException("パスワードが短すぎます")
    return User(email, password)
}

// 呼び出し側
fun main() {
    try {
        val user = createUser("", "123")
        println("登録完了: ${user.email}")
    } catch (e: IllegalArgumentException) {
        println("エラー: ${e.message}")
    }
}

問題点

  • createUserの定義を見ただけでは例外が投げられることが分からない
  • try-catchを書き忘れるとクラッシュする

After: Result型を使った実装

// Result型ベース
fun createUser(email: String, password: String): Result<User, String> {
    if (email.isBlank()) return Err("メールアドレスが空です")
    if (password.length < 8) return Err("パスワードが短すぎます")
    return Ok(User(email, password))
}

// 呼び出し側
fun main() {
    val result = createUser("", "123")

    result.onSuccess { user ->
        println("登録完了: ${user.email}")
    }.onFailure { error ->
        println("エラー: $error")
    }
}

改善点

  • 関数のシグネチャResult<User, String>を見るだけで、失敗する可能性があることが分かる

便利な拡張関数

毎回Ok()Err()を書くのは少し面倒です。拡張関数を定義すると簡潔に書けます。

fun <T> T.ok(): Result<T, Nothing> = Ok(this)
fun <T> T.err(): Result<Nothing, T> = Err(this)

これを使うと、コードがすっきりします。

// Before
fun createUser(email: String, password: String): Result<User, String> {
    if (email.isBlank())return Err("メールアドレスが空です")
	  ...
    return Ok(User(email, password))
}

// After: 拡張関数を使ってすっきり
fun createUser(email: String, password: String): Result<User, String> {
    if (email.isBlank()) return "メールアドレスが空です".err()
    ...
    return User(email, password).ok()
}

3. sealed interfaceでエラー型を定義する

なぜStringではダメなのか

先ほどの例ではResult<User, String>としました。シンプルなケースではこれで問題ありませんが、エラーの種類によって処理を分けたい場合は不十分です。

// 失敗例: エラーがStringだと...
fun createUser(email: String, password: String): Result<User, String> {
    if (email.isBlank()) return "メールアドレスが空です".err()
    if (password.length < 8) return "パスワードが短すぎます".err()
    return User(email, password).ok()
}

// 呼び出し側: エラーの種類で処理を分けられない!
createUser(email, password).onSuccess { user ->
    println("登録完了: ${user.email}")
}.onFailure { error ->
    // error変数はすでにString型なのでそのまま判定可能
    if (error.contains("空です")) {
        // ...
    } else if (error.contains("短すぎます")) {
        // ...
    }
    // これは辛い...文字列が変わったらバグる
}

問題点

  • 文字列の比較は脆い(タイポや文言変更でバグる)
  • どんなエラーがあるか一覧できない
  • 新しいエラーを追加しても、呼び出し側が気づけない

sealed interfaceでエラー型を定義する

// 良い例: sealed interfaceでエラー型を定義
sealed interface UserCreateError {
    // 追加情報が不要なエラーは data object
    data object EmailRequired : UserCreateError
    data object PasswordTooShort : UserCreateError

    // 追加情報が必要なエラーは data class(※新しく追加)
    data class InvalidEmailFormat(val email: String) : UserCreateError
}

fun createUser(email: String, password: String): Result<User, UserCreateError> {
    if (email.isBlank()) return UserCreateError.EmailRequired.err()
    if (password.length < 8) return UserCreateError.PasswordTooShort.err()
    // (※新しく追加)
    if (!email.contains("@")) return UserCreateError.InvalidEmailFormat(email).err()
    return User(email, password).ok()
}

when式で全てのエラーを処理する

val result = createUser(email, password)

result.onSuccess { user ->
    println("登録完了: ${user.email}")
}.onFailure { error ->
    // コンパイラが全ケースのカバーを保証
    when (error) {
        is UserCreateError.EmailRequired -> {
            println("メールアドレスを入力してください。")
        }
        is UserCreateError.PasswordTooShort -> {
            println("パスワードは8文字以上で入力してください。")
        }
        is UserCreateError.InvalidEmailFormat -> {
            // スマートキャストによりプロパティにアクセス可能
            println("「${error.email}」は有効なメールアドレスではありません。")
        }
    }
}

改善点

  • when式で全パターンチェックしないとコンパイルが通らない(処理忘れを防げる)

data objectとdata classの使い分け

種類 用途
data object 追加情報が不要なシンプルなエラー data object EmailRequired
data class エラーに関連する情報を持たせたい data class InvalidEmailFormat(val email: String)

エラーメッセージの生成

エラー型にメッセージ生成メソッドを追加すると便利です。

sealed interface UserCreateError {
    // 全てのエラー型が実装すべきメソッドを定義
    fun toMessage(): String

    data object EmailRequired : UserCreateError {
        override fun toMessage() = "メールアドレスを入力してください。"
    }

    data object PasswordTooShort : UserCreateError {
        override fun toMessage() = "パスワードは8文字以上で入力してください。"
    }

    data class InvalidEmailFormat(val email: String) : UserCreateError {
        override fun toMessage() = "「$email」は有効なメールアドレスではありません。"
    }
}

// 使用例
createUser(email, password).onSuccess {
    showSuccess()
}.onFailure { error ->
    // 型安全にメッセージを取得
    val message = error.toMessage()
    println(message)
}

4. Result型の処理メソッド

kotlin-resultライブラリには便利なメソッドがあります。ここではよく使われるものを紹介します。

getOrThrow: Result の世界から脱出する

getOrThrowは、成功時は値を返し、失敗時は指定した例外を投げます。

fun <V, E> Result<V, E>.getOrThrow(): V

fun execute(email: String, password: String): User {
    // 失敗した場合は、指定した例外に変換して投げる
    return createUser(email, password).getOrThrow { error ->
        IllegalArgumentException(error.toMessage())
    }
}

関連メソッド

getOrDefault(default: V)      // 失敗時はデフォルト値
getOrElse { error -> ... }    // 失敗時は関数で計算

andThen: 処理をチェーンする

andThenは、成功時に次の処理を実行します。失敗時はそのまま失敗を返します。fail-fast 方式で、最初のエラーで処理が止まります。(flatMap と同じ)

fun <V, E, U> Result<V, E>.andThen(transform: (V) -> Result<U, E>): Result<U, E>

fun createAndSaveUser(email: String, password: String): Result<User, UserCreateError> {
    return createUser(email, password)
        // createUser が失敗した場合、andThen の中身は実行されず、エラーがそのまま返される
        .andThen { user -> userRepository.save(user) }
}

mapError:エラー型を変換

mapErrorは、失敗時のエラーを別の型に変換します。

fun <V, E, F> Result<V, E>.mapError(transform: (E) -> F): Result<V, F>

例えば、内部のエラーを外部向けのエラーに変換する。

sealed interface ApiError {
    data class BadRequest(val message: String) : ApiError
    data class InternalError(val cause: Throwable) : ApiError
}

fun createUserEndpoint(email: String, password: String): Result<User, ApiError> {
    return createUser(email, password)
        // ドメイン層のエラー(UserCreateError)を
        // API層のエラー(ApiError)に変換する
        .mapError { error ->
            when (error) {
                is UserCreateError.EmailRequired -> ApiError.BadRequest(error.toMessage())
                is UserCreateError.PasswordTooShort -> ApiError.BadRequest(error.toMessage())
                is UserCreateError.InvalidEmailFormat -> ApiError.BadRequest(error.toMessage())
            }
        }
}

5. まとめ

Before / After 比較

観点 例外ベース Result型ベース
エラーの可視性 見えない 型で明示
エラー処理の強制 なし コンパイラがチェック
エラーの種類判別 instanceof / catch分岐 onSuccess / onFailure

ベストプラクティス

  • sealed interfaceでエラー型を定義する(Stringは使わない)
  • data objectとdata classを使い分ける(情報が必要かどうか)
  • ライブラリのメソッド(andThen、mapError、getOrThrowなど)を活用してResult型を処理する

おわりに

Result型とsealed interfaceの組み合わせにより、コンパイル時にエラー処理漏れを発見でき、エラーの種類ごとに適切な処理が可能になります。

最初は少し冗長に感じるかもしれませんが、一度慣れると快適です。ぜひ試してみてください!

参考リンク

株式会社ログラス テックブログ

Discussion