Share
Embed
例外からRich Errorsまで Kotlinエラーハンドリングの進化を読み解く
Kotlin Fest 2026で発表したスライドです。
https://2026.kotlinfest.dev/sessions/session-08
shiita0903
November 14, 2026
Transcript
47 slides
-
自己紹介 • Yuta Takanashi • X: @shiita̲0903 • LINEヤフー株式会社 /
LINEのAndroidアプリ開発 • 著書: Ready for review チームを支えるコードレビュー -
エラーをどこに・どう表すか 手法 どこに どう表すか 例外 コメントまたはアノテーション KDoc の @throws、@Throws Null
戻り値 null kotlin.Result 戻り値 Throwable 独自Result 戻り値 専用の sealed class Either(Arrow) 戻り値 エラーの型パラメータ Raise(Arrow) Context parameters Raise<E> の E Rich Errors 戻り値 エラー型のユニオン -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
例外 fun loadUser(id: String): User { val raw = api.fetch(id)
IOException を投げる可能性がある return parse(raw) ParseException を投げる可能性がある } try { val user = loadUser("123") println(user.name) } catch (e: IOException) { ネットワークエラー } catch (e: ParseException) { 不正なデータ } / / / / / / / / • シグネチャを見ても、何が投げられるか分からない • catch し忘れても、コンパイラは何も教えてくれない -
回復不可能: 前提違反(呼び出し側のバグ) fun setAge(age: Int) { IllegalArgumentException require(age 0) {
"age must be non-negative: $age" } } 回復可能: ネットワークエラー(呼び出し側が対処したい) try { val user = loadUser("123") } catch (e: IOException) { showRetryDialog() } = > . . . / / / / / • エラーは回復可能なものと回復不可能なものに分けて考えられる • 回復不可能なエラーは、例外を使いクラッシュさせたりする / / / 回復可能 vs 回復不可能 -
検査例外は高階関数と相性が悪い static User loadUser(String id) throws IOException { List<User> users
= ids.stream() .map(id loadUser(id)) .toList(); ❌ Unhandled exception: IOException • ラムダの中では検査例外を投げられないので、catch するか . . . / / > RuntimeException に包むしかない - } -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
val port = input.toIntOrNull() 8080 nullのとき、空リストと最初の要素がnullの区別がつかない val list: List<String?> =
listOf(null, "a") val first = list.firstOrNull() nullのとき、fetchUser()とcharge()どちらが失敗したのか区別がつかない val result = fetchUser() charge() : ? . ? . ? : / • エラーが戻り値の型(T?)に現れ、処理を忘れることが無い で手軽に扱える • や • エラーが起きるかどうかしか表せない / / / ? Null をエラーとして使う -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
kotlin.Result value class Result<out T>(val value: Any?) { companion object
{ public inline fun <T> success(value: T): Result<T> = Result(value) public inline fun <T> failure(exception: Throwable): Result<T> = Result(createFailure(exception)) } } • エラーがThrowableとして戻り値の型(Result<T>)に現れる • 本来は、コルーチンで例外を値として受け渡すための道具 • Kotlin 1.3〜1.4 では、戻り値の型にすることすらできなかった -
runCatching と CancellationException suspend fun loadData(): Result<Data> = runCatching {
api.fetchData() // キャンセルされると CancellationException が投げられる } • runCatching は CancellationException も捕捉してしまう • キャンセルされたはずの処理が、そのまま続いてしまう • 捕捉した CancellationException は投げ直す必要がある -
例外の型が分からない val result: Result<User> = loadUser("123") result.fold( onSuccess = {
user println(user.name) }, onFailure = { throwable 型からはどのエラーか分からない when (throwable) { is IOException retry() is ParseException showInvalidDataError() else throw throwable } } ) / / > - > - > - > - > - • どんな例外が来るかは、Result<User>型からは分からない • when に else が必要になり、網羅性のチェックが効かない -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
sealed class で 独自Result を定義する sealed class LoadUserResult { data
class Success(val user: User) : LoadUserResult() data object NotFound : LoadUserResult() data class NetworkError(val cause: Throwable) : LoadUserResult() } fun loadUser(id: String): LoadUserResult { } when (val result = loadUser("123")) { is LoadUserResult.Success println(result.user.name) is LoadUserResult.NotFound showNotFound() is LoadUserResult.NetworkError retry() } / * . . . * / > - > - > - • エラーが戻り値の型に現れ、種類ごとにサブクラスとして表せる • when で網羅的に分岐でき、else が要らない -
プロジェクト内のユースケースごとにこれを繰り返す… sealed class LoadUserResult { } sealed class SaveUserResult {
} sealed class DeleteUserResult { } / * / / * * . . . . . . . . . * / * * / / • ユースケースごとに sealed class が1つ必要 • 共通のエラー(NotFound など)も、sealed class ごとに宣言が必要 / / 冗長なコード -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか ( • 比較と選び方 -
Either sealed class Either<out A, out B> { data class
Left<A>(val value: A) : Either<A, Nothing>() data class Right<B>(val value: B) : Either<Nothing, B>() } fun loadUser(id: String): Either<LoadError, User> { } when (val result = loadUser("123")) { is Either.Left handleError(result.value) is Either.Right println(result.value.name) } / * . . . * / > - > - • 独自Resultを汎用的にしたもの(中身はただの sealed class) • エラーが戻り値の型に、型パラメータ(Left 側)として現れる • 慣例として、Left がエラー、Right が成功 -
Eitherをそのまま使う fun loadUser(userId: String): Either<AppError, User> { } fun loadProfile(profileId:
String): Either<AppError, Profile> { fun process(profile: Profile): Either<AppError, ProcessedData> { } } fun loadAndProcess(id: String): Either<AppError, ProcessedData> { val user = when (val r = loadUser(id)) { is Either.Left return r エラーならそのまま返す is Either.Right r.value 成功なら中身を取り出す } val profile = when (val r = loadProfile(user.profileId)) { is Either.Left return r is Either.Right r.value } return process(profile) } / / * * . . . . . . * * / / / * . . . * / / / / / > > > - - > - - • 呼び出すたびに when で中身を取り出し、Left なら return する -
either { } と bind() fun loadUser(userId: String): Either<AppError, User>
{ } fun loadProfile(profileId: String): Either<AppError, Profile> { fun process(profile: Profile): Either<AppError, ProcessedData> { } } fun loadAndProcess(id: String): Either<AppError, ProcessedData> = either { val user = loadUser(id).bind() Left ならここで抜ける val profile = loadProfile(user.profileId).bind() process(profile).bind() } / / * * . . . . . . * * / / / * . . . * / / / • either { } の中では、bind() で中身を取り出せる • Left なら、その場で either { } を抜けてエラーを返す • 前のスライドの when と return を、1行にまとめたもの -
context(logger: Logger) fun loadUser(id: String): User { logger.info("loading $id") return
api.fetch(id) } 呼び出し側 — logger はスコープから暗黙的に解決される context(myLogger) { val user = loadUser("123") } • 関数に、暗黙の引数を渡す言語機能 • Kotlin 2.2 でプレビューとして導入、2.4 で Stable / / Context parameters -
Raise<E> context(_: Raise<AppError>) fun loadUser(userId: String): User { raise(AppError()) }
context(_: Raise<AppError>) fun loadProfile(profileId: String): Profile { } context(_: Raise<AppError>) fun process(profile: Profile): ProcessedData { } context(_: Raise<AppError>) fun loadAndProcess(id: String): ProcessedData { val user = loadUser(id) raise されると、ここで処理が中断される val profile = loadProfile(user.profileId) return process(profile) } / / * * . . . . . . * * / / / / • エラーが Context parameters(Raise<E>)に現れる • raise() は throw のように処理を中断するが、宣言したエラーしか投げられない • 検査例外と違って、map などの高階関数の中でも raise() できる -
Either と Raise の比較 fun loadAndProcess(id: String): Either<AppError, ProcessedData> =
either { val user = loadUser(id).bind() val profile = loadProfile(user.profileId).bind() process(profile).bind() } context(_: Raise<AppError>) fun loadAndProcess(id: String): ProcessedData { val user = loadUser(id) val profile = loadProfile(user.profileId) return process(profile) } • エラーの型が、戻り値から Context parameters に移る • either { } や bind() を書かずに済む -
異なるエラーを組み合わせる context(_: Raise<UserError>) fun loadUser(id: String): User { context(_: Raise<PaymentError>)
fun pay(user: User): Receipt { } } context(_: Raise<CheckoutError>) fun checkout(id: String): Receipt { val user = withError(UserError toCheckoutError) { loadUser(id) } return withError(PaymentError toCheckoutError) { pay(user) } } / * / . * . . . . : * . : : / : * / • Either<E, A> や1つの Raise<E> が表せるエラーの型は1つ • 異なるエラーを返す関数を呼ぶには、共通の型へ withError で変換 -
エラーをまとめて集める context(_: RaiseAccumulate<FieldError>) fun validateName(name: String): Name { raise(FieldError()) }
context(_: RaiseAccumulate<FieldError>) fun validateEmail(email: String): Email { } context(_: Raise<NonEmptyList<FieldError ) fun RegisterForm.validate(): ValidForm = zipOrAccumulate( { validateName(name) }, 失敗しても次へ進む { validateEmail(email) }, ) { validName, validEmail すべて成功したときだけ呼ばれる ValidForm(validName, validEmail) } / * . . . * / > > / / / / > - • 最初のエラーで止まらず、すべての検証を実行する • エラーは NonEmptyList にまとめて返される -
途中の関数はエラーを処理せず、伝播するだけ context(_: Raise<AppError>) fun loadAndProcess(id: String): ProcessedData { } エラーを最終的に処理する場所(ViewModel
など)でまとめて処理する fun onLoad(id: String) { _uiState.value = either { loadAndProcess(id) }.fold( ifLeft = { error UiState.Error(error.toMessage()) }, ifRight = { data UiState.Success(data) }, ) } / * . . . * / > > - - / • 最終的に処理する場所の either { } が、catch のように受け止める • 途中の関数はエラー処理を書かず、最終的に処理する場所でだけ処理する / / / エラーはどこで処理するか -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
Rich Errors: 注意事項 • まだ提案段階(KEEP-0462 は Public discussion) • KEEP-0441(動機)と
KEEP-0462(本提案)の、 2026-11-13 時点の内容を参照 • 細部は変わる可能性が高いので、大きく変わらなさそうな 全体像を中心に紹介する -
なぜ今提案されているのか 手法 問題点 Rich Errors の目指す場所 例外 エラーが型に現れない 戻り値の型に現れる Null
どんなエラーかを表せない エラーごとに型を定義できる kotlin.Result エラーの種類が型に出ない 起こりうるエラーが型に並ぶ 独自Result 冗長な定義 エラーを一度だけ定義し、ユニオンで組み合わせる Arrow 外部ライブラリが必要 言語機能として提供される -
Rich Errors の構文 error object NotFound error class InvalidId(val raw:
String) fun loadUser(id: String): User | NotFound | InvalidId { if (id.isBlank()) return InvalidId(id) return api.findUser(id) NotFound } when (val result = loadUser("123")) { is User println(result.name) is NotFound showNotFound() is InvalidId showInvalidId(result.raw) } : ? > - > - > - • error object / error class でエラーの型を宣言する • エラーが戻り値の型に、エラー型のユニオン(|)として現れる • 成功の値は包まずに、そのまま返せる -
|. によるチェインとエラーの自動合成 fun loadUser(userId: String): User | UserNotFound { }
fun loadProfile(profileId: String): Profile | ProfileNotFound { fun process(profile: Profile): ProcessedData | InvalidProfile { } } fun loadAndProcess(id: String): ProcessedData | UserNotFound | ProfileNotFound | InvalidProfile = loadUser(id) |.let { user loadProfile(user.profileId) } |.let { profile process(profile) } / / * * . . . . . . * * / / / * . . . * / > - > - . ? のエラー版: 左がエラーならそのまま返し、そうでなければ次を呼ぶ • |. は • 異なるエラーを返す関数をつなぐと、エラーのユニオンが自動で合成される • 独自Resultのように、sealed class を設計する必要がない -
スマートキャスト fun handle(result: User | NotFound | InvalidId) { if
(result is NotFound) return result は User | InvalidId にスマートキャストされる if (result is InvalidId) return result は User にスマートキャストされる showUser(result) } • 早期リターンでエラーを除外すると、型が絞り込まれる • 包まれていないので、絞り込んだ後はそのまま User として使える • ※ 否定的なスマートキャスト(除外したエラーを型から取り除く動き)は、 / / / / エラーユニオン本体より後に提供される可能性がある -
> - null で書く場合 fun <T> Sequence<T>.last(predicate: (T) Boolean): T
{ var last: T? = null var found = false for (element in this) { if (predicate(element)) { last = element; found = true } } if (!found) throw NoSuchElementException() @Suppress("UNCHECKED_CAST") return last as T } / / 実装の内部でも使える -
Rich Errors で書く場合 private error object NotFoundYet 外に公開しないエラー fun <T>
Sequence<T>.last(predicate: (T) Boolean): T { var last: T | NotFoundYet = NotFoundYet for (element in this) { if (predicate(element)) last = element } if (last NotFoundYet) throw NoSuchElementException() return last T にスマートキャスト } > - / / / / = = • 公開しないエラーを、まだ見つかっていない途中の状態を表す印として使う • フラグ変数や非検査キャストが要らなくなる / / 実装の内部でも使える -
エラーのときだけ処理し、成功なら値をそのまま受け取る val user: User = loadUser(id).ifError { error when (error)
{ is NotFound showNotFound() is InvalidId showInvalidId(error.raw) } return } エラーを例外に変換する val user2: User = loadUser(id).throwIfError() : > ? - > - > - / return に近い) • ifError { }: エラーのときだけ処理する(null の • throwIfError(): エラーを例外に包んで投げる(回復不可能として扱う) / / / エラーを処理する・例外にする -
欲しいもの: 複数の入力エラーをまとめて返すエラー ❌ エラー型にジェネリクスは使えない error class Errors<E>(val errors: List<E>) fun
validate(form: Form): Unit | Errors<FieldError> / • 現在の提案では、エラー型にジェネリクスを付けられない • 汎用的なエラー(Errors<E> など)を定義できない • 型推論を重くしないための制約で、将来の緩和も議論されている / / / 弱点: エラー型にジェネリクスを使えない -
Raise: 途中の関数はエラー処理を書かずに済む context(_: Raise<OrderError>) fun checkout(userId: String, cartId: String): Order
{ val user = loadUser(userId) val cart = loadCart(cartId) for (item in cart.items) { reserve(item) } return createOrder(user, cart) } • loadUser・loadCart・reserve は、どれもエラーを raise する可能性 がある • 複数の結果を組み合わせたり、ループしたりする処理でも問題ない / / 弱点: エラーで処理を打ち切る構文がない -
Rich Errors: 呼び出しごとに ifError { return it } が必要 fun
checkout(userId: String, cartId: String): Order | UserNotFound | CartNotFound | OutOfStock { val user = loadUser(userId).ifError { return it } val cart = loadCart(cartId).ifError { return it } for (item in cart.items) { reserve(item).ifError { return it } } return createOrder(user, cart) } • 複数の結果を組み合わせたり、ループしたりする処理は |. でつなげない • そのたびに ifError { return it } が必要になる • 現在の提案には、エラーで処理を打ち切る構文は含まれていない / / 弱点: エラーで処理を打ち切る構文がない -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
回復可能性は誰が決めるのか configValue.toInt() userInput.toIntOrNull() // 不正な文字列なら NumberFormatException // 不正な文字列なら null •
設定ファイルの値なら、不正な文字列は前提違反(回復不可能) • ユーザーの入力なら、不正な文字列は想定内(回復可能) • 同じエラーでも、回復可能かどうかは呼び出し側の文脈で決まる -
関数を作る側の目安 エラーの種類 関数を作る側の目安 呼び出す前に簡単に確認できる (インデックスの範囲、空かどうか) 例外でよい (確認せずに呼ぶのは呼び出し側のバグ) やってみないと分からない (パース、ネットワーク、ファイル) 型で表し(戻り値や
Raise)、 回復するかは呼び出し側に任せる • 型で表したエラー → 例外の変換 は簡単 • 例外 → 型で表したエラーの変換 はtry/catch が必要 -
アジェンダ • 例外 • Null • kotlin.Result • 独自Result •
Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方 -
比較と選び方 手法 エラーが型に現れる 伝播 ◎ 自動 エラーの合成 ― ◦ △
return ― kotlin.Result ◦ △ getOrElse で手動 ― 独自Result ◦ 手動 ◦ 手動 Either(Arrow) ◦ ◦ bind ◦ △ 手動の型変換 Raise(Arrow) ◦ ◎ 自動 ◦ △ 手動の型変換 Rich Errors ◦ △ ifError ◦ ◎ 自動合成 : Null ? . ? 例外 エラーの種類の区別 、 -
比較と選び方 場面 今 Rich Errors 以降 回復不可能なエラー 例外 例外 エラーの種類が1つで、区別が不要
Null Rich Errors エラーの種類を区別し、呼び出し元ですぐ処理する 独自Result / Either Rich Errors エラーが起きる場所と処理する場所が離れている Raise Raise -
まとめ • エラーをどこに・どう表すかで、エラーハンドリングの 使い勝手が決まる • Rich Errors は合成とスマートキャストが強みだが、 処理を打ち切る構文やジェネリクスは課題 •
回復可能かどうかは呼び出し側が決める。迷ったら型で 表し、選択肢を残す • Null・独自Result・Either が担ってきた役割は、 Rich Errors に集約されていく -
参考文献 • Roman Elizarov, "Kotlin and Exceptions" https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07 • KEEP-0441
"Rich Errors: Motivation and Rationale" https://github.com/Kotlin/KEEP/blob/main/proposals/KEEP-0441-rich-errors-motivation.md • KEEP-0462 "Rich Errors" https://github.com/Kotlin/KEEP/blob/main/proposals/KEEP-0462-rich-errors.md • KEEP Discussion #447 / #487 https://github.com/Kotlin/KEEP/discussions/447 https://github.com/Kotlin/KEEP/discussions/487 • KT-88849(Rich Errors の実装) https://youtrack.jetbrains.com/issue/KT-88849