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

KotlinでミニマルなResult実装による関数型エラーハンドリング

 KotlinでミニマルなResult実装による関数型エラーハンドリング

Kotlinでの(サードパーティライブラリに依存しない)必要最小限のResult実装による関数型エラーハンドリングについてご紹介します。

Avatar for Kent OHASHI

Kent OHASHI

November 25, 2025
Tweet

More Decks by Kent OHASHI

Other Decks in Programming

Transcript

  1. のシニアエンジニア スタートアップと投資家のやり取りを効率化する データ管理プラットフォームを開発している 技術スタック: Kotlin/Ktor & TypeScript/Vue.js の運営にも協力 , などの関数型言語の愛好者

    の運営スタッフ( 座長のひとり) Java, , Clojure, Kotlin とJVM 言語での開発経験 Kotlin の実務利用は1 年半ほど🐣 lagénorhynque 🐬カマイルカ 株式会社スマートラウンド Server-Side Kotlin Meetup Clojure Haskell 関数型まつり Scala 2
  2. B. エラーを表す型によるエラーハンドリング ( 静的型付き) 関数型言語でよくある設計パターン Either: Haskell, Scala, etc. Result:

    OCaml, Elm, Rust, etc. ただの( ふさわしい型が付いた) 値なので: 自由に組み合わせられる 便利な関数などを用意して操作を抽象化できる エラーを明示的に扱いたい場合にほしくなる 10
  3. 成功値 Success or 失敗値 Failure (Throwable) 関数型言語でいう Either/Result ほど汎用的 ではない

    Kotlin 標準ライブラリの Result fun mean(xs: List<Double>): Result<Double> = if (xs.isEmpty()) Result.failure( IllegalArgumentException("mean of empty list!") ) else Result.success(xs.sum() / xs.size) val x1 = mean(listOf(1.0, 2.0, 3.0)).getOrThrow() // 2.0 val y1 = mean(listOf(4.0, 5.0)).getOrThrow() // 4.5 val result1 = x1 + y1 // => 6.5 val x2 = mean(listOf(1.0, 2.0, 3.0)).getOrThrow() // 2.0 val y2 = mean(emptyList()).getOrThrow() // 例外発生 val result2 = x2 + y2 // 到達しない 12
  4. 成功値 Right or 失敗値 Left によってフラットに命令的に記述 できる Arrow の Either

    fun mean(xs: List<Double>): Either<String, Double> = if (xs.isEmpty()) Either.Left("mean of empty list!") else Either.Right(xs.sum() / xs.size) either { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Right(2.0) val y = mean(listOf(4.0, 5.0)).bind() // Right(4.5) x + y } // => Right(6.5) either { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Right(2.0) val y = mean(emptyList()).bind() // Left(...) x + y } // => Left("mean of empty list!") either メソッド 13
  5. 成功値 Ok or 失敗値 Err によってフラットに命令的に記 述できる kotlin-result の Result

    fun mean(xs: List<Double>): Result<Double, String> = if (xs.isEmpty()) Err("mean of empty list!") else Ok(xs.sum() / xs.size) binding { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Ok(2.0) val y = mean(listOf(4.0, 5.0)).bind() // Ok(4.5) x + y } // => Ok(6.5) binding { val x = mean(listOf(1.0, 2.0, 3.0)).bind() // Ok(2.0) val y = mean(emptyList()).bind() // Err(...) x + y } // => Err("mean of empty list!") binding メソッド 14
  6. [ 参考] 成功値 Right or 失敗値 Left (flatMap, map などのメソッドの連鎖に対する

    汎用的なシンタックスシュガー) が便利 Scala 標準ライブラリの Either def mean(xs: Seq[Double]): Either[String, Double] = if xs.isEmpty then Left("mean of empty list!") else Right(xs.sum / xs.length) for x <- mean(Seq(1, 2, 3)) // Right(2) y <- mean(Seq(4, 5)) // Right(4.5) yield x + y // => Right(6.5) for x <- mean(Seq(1, 2, 3)) // Right(2) y <- mean(Seq(4, 5)) // Left("mean of empty list!") yield x + y // => Left("mean of empty list!") for 式 15
  7. ImportResult 型: インポート処理のためのResult 型 // 代数的データ型(Success or Failure の直和型) sealed

    interface ImportResult<out T> { data class Success<out T>(val value: T) : ImportResult<T> data class Failure(val errors: List<ImportError>) : ImportResult<Nothing> { init { // 代わりにnon-empty list を用意してもよい require(errors.isNotEmpty()) } } val isSuccess: Boolean get() = this is Success val isFailure: Boolean get() = this is Failure // 以下、便利な関数を定義( 後述) } 17
  8. ImportError 型: インポートに関するエラー情報 ImportResult.Success<T>: T 型の成功値 ImportResult.Failure: List<ImportError> の失敗値 内容は

    ImportError リストに固定した( 汎用的 な実装ではなく用途特化で扱いやすくするため) data class ImportError( val line: Int, val message: String, ) 18
  9. ImportResult インターフェースの便利な関数 fold 関数: 成功値、失敗値それぞれに関数適用して 同じ型の値に畳み込む cf. List に対するfold 関数

    概念的には、空(nil) もしくは要素を持つ(cons) 再帰的な代数的データ型 典型的な操作でwhen による場合分けが不要に fun <U> fold(valueFn: (T) -> U, errorsFn: (List<ImportError>) -> U): U = when (this) { is Success -> valueFn(value) is Failure -> errorsFn(errors) } 19
  10. flatMap 関数: 成功値に ImportResult を返す関数 を適用する 失敗値はそのまま cf. List に対するflatMap

    関数 ℹ️ Haskell では (bind) fun <U> flatMap(f: (T) -> ImportResult<U>): ImportResult<U> = fold( { f(it) }, { Failure(it) }, ) Monad 型クラスの >>= 21
  11. map 関数: 成功値に関数を適用する 失敗値はそのまま cf. List に対するmap 関数 ℹ️ Haskell

    では flatMap があればmap は実装できる ℹ️ なぜなら、Monad ⇒ Functor fun <U> map(f: (T) -> U): ImportResult<U> = flatMap { Success(f(it)) } Functor 型クラスの fmap 22
  12. sequence 関数: List<ImportResult<A>> を ImportResult<List<A>> に変換する 失敗値があれば ImportError リストに集約 fun

    <A> List<ImportResult<A>>.sequence(): ImportResult<List<A>> = this.fold(Success(emptyList<A>()) as ImportResult<List<A>>) { acc, result -> acc.fold( { accValues -> // 累積値が成功の場合 result.fold( { Success(accValues + it) }, { Failure(it) }, ) }, { accErrors -> // 累積値が失敗の場合 result.fold( { Failure(accErrors) }, { Failure(accErrors + it) }, ) }, ) } 23
  13. 利用例: 取引のCSV ファイルのインポート処理 import: メインの関数 成功値: インポートした取引データのリスト 失敗値: パース、チェック、DB データ補完、保存

    のいずれかの段階で発生したエラー情報リスト fun import( csv: InputStream, targetId: TargetId, ctx: AppContext, ): ImportResult<List<TransactionInput>> = parse(csv) .flatMap { validate(it) } .flatMap { fillWithStoredData(it, targetId, ctx) } .flatMap { save(it, ctx) } 24
  14. parse: CSV ファイルのパース処理を行う関数 成功値: パース済みのCSV 行リスト 失敗値: パース過程でのエラー情報リスト private fun

    parse(csv: InputStream): ImportResult<List<ParsedCsvRow>> = csvReader().open(csv) { readAllWithHeaderAsSequence() .mapWithLineNumber { line, row -> val ctx = RowContext(line, row) ParsedCsvRow.from( executionDate = extractRequiredColumn(" 取引日", ctx) .flatMap { coerceAsLocalDate(it, " 取引日", ctx) }, amount = extractRequiredColumn(" 金額", ctx) .flatMap { coerceAsBigDecimal(it, " 金額", ctx) }, // 以下、その他の列の抽出が続く ) }.toList().sequence() } 25
  15. extractRequiredColumn: 必須の列を抽出する 関数 成功値: 抽出された文字列 失敗値: 抽出過程のエラー情報リスト private fun extractRequiredColumn(

    columnName: String, rowContext: RowContext, ): ImportResult<String> = extractColumn(columnName, rowContext) .flatMap { value -> value?.let { ImportResult.Success(it) } ?: ImportResult.singleError( line = rowContext.line, message = "${columnName} は必須です", ) } 26
  16. extractColumn: 列を抽出する関数 成功値: 抽出された文字列 失敗値: なし private fun extractColumn( columnName:

    String, rowContext: RowContext, ): ImportResult<String?> = rowContext.row[columnName]?.trim() .let { ImportResult.Success(it) } 27
  17. validate: 入力形式のチェックを行う関数 成功値: チェック済みのCSV 行リスト 失敗値: チェック過程のエラー情報リスト private fun validate(rows:

    List<ParsedCsvRow>): ImportResult<List<ValidatedCsvRow>> = rows.mapWithLineNumber { line, row -> ValidatedCsvRow.from( row, listOf( validateSingleFields(row, line), validateFieldRelations(row, line), ) ) } .sequence() 28
  18. validateSingleFields: CSV 行の各フィールド をチェックする関数 成功値: Unit ( 意味のある結果がないため) 失敗値: バリデーションエラー情報リスト

    private fun validateSingleFields( row: ParsedCsvRow, line: Int, ): ImportResult<Unit> = Validator.validate(row).let { errorMap -> if (errorMap.isEmpty()) ImportResult.Success(Unit) else ImportResult.Failure(errorMap.values.map { ImportError(line = line, message = it) }) } 29
  19. Further Reading Kotlin 標準ライブラリの の Scala 標準ライブラリの サンプルコード: サンプルコード: Result

    Arrow Either kotlin-result Either 『Scala 関数型デザイン&プログラミング』 fpinscala/fpinscala 『なっとく!関数型プログラミング』 miciek/grokkingfp-examples 『関数型ドメインモデリング』 31