Upgrade to Pro — share decks privately, control downloads, hide ads and more …

例外からRich Errorsまで Kotlinエラーハンドリングの進化を読み解く

shiita0903
November 14, 2026

例外からRich Errorsまで Kotlinエラーハンドリングの進化を読み解く

Kotlin Fest 2026で発表したスライドです。
https://2026.kotlinfest.dev/sessions/session-08

shiita0903

November 14, 2026

Transcript

47 slides
  1. 自己紹介 • Yuta Takanashi • X: @shiita̲0903 • LINEヤフー株式会社 /

    LINEのAndroidアプリ開発 • 著書: Ready for review チームを支えるコードレビュー
  2. エラーをどこに・どう表すか 手法 どこに どう表すか 例外 コメントまたはアノテーション KDoc の @throws、@Throws Null

    戻り値 null kotlin.Result 戻り値 Throwable 独自Result 戻り値 専用の sealed class Either(Arrow) 戻り値 エラーの型パラメータ Raise(Arrow) Context parameters Raise<E> の E Rich Errors 戻り値 エラー型のユニオン
  3. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  4. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  5. 例外 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 し忘れても、コンパイラは何も教えてくれない
  6. 回復不可能: 前提違反(呼び出し側のバグ) fun setAge(age: Int) { IllegalArgumentException require(age 0) {

    "age must be non-negative: $age" } } 回復可能: ネットワークエラー(呼び出し側が対処したい) try { val user = loadUser("123") } catch (e: IOException) { showRetryDialog() } = > . . . / / / / / • エラーは回復可能なものと回復不可能なものに分けて考えられる • 回復不可能なエラーは、例外を使いクラッシュさせたりする / / / 回復可能 vs 回復不可能
  7. 検査例外は高階関数と相性が悪い static User loadUser(String id) throws IOException { List<User> users

    = ids.stream() .map(id loadUser(id)) .toList(); ❌ Unhandled exception: IOException • ラムダの中では検査例外を投げられないので、catch するか . . . / / > RuntimeException に包むしかない - }
  8. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  9. 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 をエラーとして使う
  10. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  11. 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 では、戻り値の型にすることすらできなかった
  12. runCatching と CancellationException suspend fun loadData(): Result<Data> = runCatching {

    api.fetchData() // キャンセルされると CancellationException が投げられる } • runCatching は CancellationException も捕捉してしまう • キャンセルされたはずの処理が、そのまま続いてしまう • 捕捉した CancellationException は投げ直す必要がある
  13. 例外の型が分からない 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 が必要になり、網羅性のチェックが効かない
  14. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  15. 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 が要らない
  16. プロジェクト内のユースケースごとにこれを繰り返す… sealed class LoadUserResult { } sealed class SaveUserResult {

    } sealed class DeleteUserResult { } / * / / * * . . . . . . . . . * / * * / / • ユースケースごとに sealed class が1つ必要 • 共通のエラー(NotFound など)も、sealed class ごとに宣言が必要 / / 冗長なコード
  17. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか (​ • 比較と選び方
  18. 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 が成功
  19. 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 する
  20. 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行にまとめたもの
  21. 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
  22. 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() できる
  23. 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() を書かずに済む
  24. 異なるエラーを組み合わせる 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 で変換
  25. エラーをまとめて集める 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 にまとめて返される
  26. 途中の関数はエラーを処理せず、伝播するだけ 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 のように受け止める • 途中の関数はエラー処理を書かず、最終的に処理する場所でだけ処理する / / / エラーはどこで処理するか
  27. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  28. Rich Errors: 注意事項 • まだ提案段階(KEEP-0462 は Public discussion) • KEEP-0441(動機)と

    KEEP-0462(本提案)の、 2026-11-13 時点の内容を参照 • 細部は変わる可能性が高いので、大きく変わらなさそうな 全体像を中心に紹介する
  29. なぜ今提案されているのか 手法 問題点 Rich Errors の目指す場所 例外 エラーが型に現れない 戻り値の型に現れる Null

    どんなエラーかを表せない エラーごとに型を定義できる kotlin.Result エラーの種類が型に出ない 起こりうるエラーが型に並ぶ 独自Result 冗長な定義 エラーを一度だけ定義し、ユニオンで組み合わせる Arrow 外部ライブラリが必要 言語機能として提供される
  30. 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 でエラーの型を宣言する • エラーが戻り値の型に、エラー型のユニオン(|)として現れる • 成功の値は包まずに、そのまま返せる
  31. |. によるチェインとエラーの自動合成 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 を設計する必要がない
  32. スマートキャスト fun handle(result: User | NotFound | InvalidId) { if

    (result is NotFound) return result は User | InvalidId にスマートキャストされる if (result is InvalidId) return result は User にスマートキャストされる showUser(result) } • 早期リターンでエラーを除外すると、型が絞り込まれる • 包まれていないので、絞り込んだ後はそのまま User として使える • ※ 否定的なスマートキャスト(除外したエラーを型から取り除く動き)は、 / / / / エラーユニオン本体より後に提供される可能性がある
  33. > - 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 } / / 実装の内部でも使える
  34. 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 にスマートキャスト } > - / / / / = = • 公開しないエラーを、まだ見つかっていない途中の状態を表す印として使う • フラグ変数や非検査キャストが要らなくなる / / 実装の内部でも使える
  35. エラーのときだけ処理し、成功なら値をそのまま受け取る 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(): エラーを例外に包んで投げる(回復不可能として扱う) / / / エラーを処理する・例外にする
  36. 欲しいもの: 複数の入力エラーをまとめて返すエラー ❌ エラー型にジェネリクスは使えない error class Errors<E>(val errors: List<E>) fun

    validate(form: Form): Unit | Errors<FieldError> / • 現在の提案では、エラー型にジェネリクスを付けられない • 汎用的なエラー(Errors<E> など)を定義できない • 型推論を重くしないための制約で、将来の緩和も議論されている / / / 弱点: エラー型にジェネリクスを使えない
  37. 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 する可能性 がある • 複数の結果を組み合わせたり、ループしたりする処理でも問題ない / / 弱点: エラーで処理を打ち切る構文がない
  38. 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 } が必要になる • 現在の提案には、エラーで処理を打ち切る構文は含まれていない / / 弱点: エラーで処理を打ち切る構文がない
  39. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  40. 回復可能性は誰が決めるのか configValue.toInt() userInput.toIntOrNull() // 不正な文字列なら NumberFormatException // 不正な文字列なら null •

    設定ファイルの値なら、不正な文字列は前提違反(回復不可能) • ユーザーの入力なら、不正な文字列は想定内(回復可能) • 同じエラーでも、回復可能かどうかは呼び出し側の文脈で決まる
  41. アジェンダ • 例外 • Null • kotlin.Result • 独自Result •

    Arrow(Either / Raise) • Rich Errors • 回復可能性は誰が決めるのか • 比較と選び方
  42. 比較と選び方 手法 エラーが型に現れる 伝播 ◎ 自動 エラーの合成 ― ◦ △

    return ― kotlin.Result ◦ △ getOrElse で手動 ― 独自Result ◦ 手動 ◦ 手動 Either(Arrow) ◦ ◦ bind ◦ △ 手動の型変換 Raise(Arrow) ◦ ◎ 自動 ◦ △ 手動の型変換 Rich Errors ◦ △ ifError ◦ ◎ 自動合成 : Null ? . ? 例外 エラーの種類の区別 、
  43. 比較と選び方 場面 今 Rich Errors 以降 回復不可能なエラー 例外 例外 エラーの種類が1つで、区別が不要

    Null Rich Errors エラーの種類を区別し、呼び出し元ですぐ処理する 独自Result / Either Rich Errors エラーが起きる場所と処理する場所が離れている Raise Raise
  44. まとめ • エラーをどこに・どう表すかで、エラーハンドリングの 使い勝手が決まる • Rich Errors は合成とスマートキャストが強みだが、 処理を打ち切る構文やジェネリクスは課題 •

    回復可能かどうかは呼び出し側が決める。迷ったら型で 表し、選択肢を残す • Null・独自Result・Either が担ってきた役割は、 Rich Errors に集約されていく
  45. 参考文献 • 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

More Decks by shiita0903

Other Decks in Programming