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を詳しく知りたい方は以下の記事を参考にしてください。
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の組み合わせにより、コンパイル時にエラー処理漏れを発見でき、エラーの種類ごとに適切な処理が可能になります。
最初は少し冗長に感じるかもしれませんが、一度慣れると快適です。ぜひ試してみてください!
参考リンク
- kotlin-result - 本記事で使用したライブラリ
- kotlin-result入門
Discussion