-- Views
November 25, 25
スライド概要
Kotlinでの(サードパーティライブラリに依存しない)必要最小限のResult実装による関数型エラーハンドリングについてご紹介します。
「楽しく楽にcoolにsmartに」を理想とするprogrammer/philosopher/liberalist/realist。 好きな言語はClojure, Haskell, Elixir, English, français, русский。 読書、プログラミング、語学、法学、数学が大好き! イルカと海も大好き🐬
でミニマルな Result実装による 関数型エラーハンドリング Kotlin #KotlinFest_SSK 1
🐬カマイルカ 株式会社スマートラウンドのシニアエンジニア スタートアップと投資家のやり取りを効率化する データ管理プラットフォームを開発している 技術スタック: Kotlin/Ktor & TypeScript/Vue.js Server-Side Kotlin Meetupの運営にも協力 Clojure, Haskellなどの関数型言語の愛好者 関数型まつりの運営スタッフ(座長のひとり) Java, Scala, Clojure, KotlinとJVM言語での開発経験 Kotlinの実務利用は1年半ほど🐣 lagénorhynque 2
で登壇しました! Kotlin Fest 2025 で「関数型エクササイズ」を実践しよう Functional Calisthenics in Kotlin: Kotlin 3
きっかけ エラーハンドリングのパターン の Result/Either 4. 実際に採用したResult実装 1. 2. 3. Kotlin 4
1. きっかけ 5
取り組んでいたタスク 取引(入出金)情報のCSVファイルについて形式/内容 を検証してからDB保存するインポート機能 想定する処理の流れ 1. CSVファイルのパース 2. 入力形式のチェック 3. DBの既存情報との照合/補完 4. DBへの保存 6
設計時に重視したこと CSVファイルを取り込む複数の段階で様々な理由で 失敗(エラー終了)する可能性がある → どこでどのように失敗したか把握したい エラーハンドリングを愚直に実装すると扱いづらく 読みづらくなりそう → コードをなるべく宣言的で高レベルな表現に 保ちたい ⇒ 関数型言語で定番の Either/Result 型がほしい 7
2. エラーハンドリングのパターン 8
例外のthrow/catchによるエラーハンドリング 主流言語でたいてい基本的な手法 組み合わせづらい(composabilityが低い) 例外のthrowとは大域脱出(readabilityも低い) 良くも悪くも命令型の仕組みといえる エラー情報を取り回すのにはあまり適していない エラーが回復不能/不要で暗黙的に扱えば十分な場 合には使いやすい A. 9
エラーを表す型によるエラーハンドリング (静的型付き)関数型言語でよくある設計パターン B. Either: Haskell, Scala, etc. Result: OCaml, Elm, Rust, etc. ただの(ふさわしい型が付いた)値なので: 自由に組み合わせられる 便利な関数などを用意して操作を抽象化できる エラーを明示的に扱いたい場合にほしくなる 10
の Result/Either 3. Kotlin 11
標準ライブラリの Result
Kotlin
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()
val y1 = mean(listOf(4.0, 5.0)).getOrThrow()
val result1 = x1 + y1 // => 6.5
// 2.0
// 4.5
val x2 = mean(listOf(1.0, 2.0, 3.0)).getOrThrow()
val y2 = mean(emptyList()).getOrThrow()
val result2 = x2 + y2 //
// 2.0
//
到達しない
例外発生
成功値 Success or 失敗値 Failure (Throwable)
関数型言語でいう Either/Result ほど汎用的
ではない
12
の Either
Arrow
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()
val y = mean(listOf(4.0, 5.0)).bind()
x + y
} // => Right(6.5)
either {
val x = mean(listOf(1.0, 2.0, 3.0)).bind()
val y = mean(emptyList()).bind()
x + y
} // => Left("mean of empty list!")
// Right(2.0)
// Right(4.5)
// Right(2.0)
// Left(...)
成功値 Right or 失敗値 Left
either メソッドによってフラットに命令的に記述
できる
13
の Result
kotlin-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()
val y = mean(listOf(4.0, 5.0)).bind()
x + y
} // => Ok(6.5)
binding {
val x = mean(listOf(1.0, 2.0, 3.0)).bind()
val y = mean(emptyList()).bind()
x + y
} // => Err("mean of empty list!")
// Ok(2.0)
// Ok(4.5)
// Ok(2.0)
// Err(...)
成功値 Ok or 失敗値 Err
binding メソッドによってフラットに命令的に記
述できる
14
参考] 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!")
成功値 Right or 失敗値 Left
for式(flatMap, mapなどのメソッドの連鎖に対する
汎用的なシンタックスシュガー)が便利
15
4. 実際に採用したResult実装 16
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
ImportError 型: インポートに関するエラー情報 data class ImportError( val line: Int, val message: String, ) ImportResult.Success<T>: T 型の成功値 ImportResult.Failure: List<ImportError> の失敗値 内容は ImportError リストに固定した(汎用的 な実装ではなく用途特化で扱いやすくするため) 18
ImportResult
インターフェースの便利な関数
fun <U> fold(valueFn: (T) -> U, errorsFn: (List<ImportError>)
-> U): U =
when (this) {
is Success -> valueFn(value)
is Failure -> errorsFn(errors)
}
関数: 成功値、失敗値それぞれに関数適用して
同じ型の値に畳み込む
cf. Listに対するfold関数
概念的には、空(nil)もしくは要素を持つ(cons)
再帰的な代数的データ型
典型的な操作でwhenによる場合分けが不要に
fold
19
fun getValueOrThrow(): T = this.fold( { it }, { throw IllegalStateException( "ImportResult is Failure: $it" ) }, ) 関数: 成功値の中身を取り出す 失敗値に対しては動作しない get 20
fun <U> flatMap(f: (T) -> ImportResult<U>): ImportResult<U> =
fold(
{ f(it) },
{ Failure(it) },
)
関数: 成功値に ImportResult を返す関数
を適用する
失敗値はそのまま
cf. Listに対するflatMap関数
ℹ️ HaskellではMonad型クラスの >>= (bind)
flatMap
21
fun <U> map(f: (T) -> U): ImportResult<U> =
flatMap { Success(f(it)) }
関数: 成功値に関数を適用する
失敗値はそのまま
cf. Listに対するmap関数
ℹ️ HaskellではFunctor型クラスの fmap
flatMapがあればmapは実装できる
ℹ️ なぜなら、Monad ⇒ Functor
map
22
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) },
) },
)
}
累積値が成功の場合
累積値が失敗の場合
関数: List<ImportResult<A>> を
ImportResult<List<A>> に変換する
失敗値があれば ImportError リストに集約
sequence
23
利用例: 取引のCSVファイルのインポート処理
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) }
メインの関数
成功値: インポートした取引データのリスト
失敗値: パース、チェック、DBデータ補完、保存
のいずれかの段階で発生したエラー情報リスト
import:
24
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()
}
以下、その他の列の抽出が続く
取引日
取引日
金額
金額
ファイルのパース処理を行う関数
成功値: パース済みのCSV行リスト
失敗値: パース過程でのエラー情報リスト
parse: CSV
25
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}
",
)
}
は必須です
extractRequiredColumn:
必須の列を抽出する
関数
成功値: 抽出された文字列
失敗値: 抽出過程のエラー情報リスト
26
private fun extractColumn(
columnName: String,
rowContext: RowContext,
): ImportResult<String?> =
rowContext.row[columnName]?.trim()
.let { ImportResult.Success(it) }
列を抽出する関数
成功値: 抽出された文字列
失敗値: なし
extractColumn:
27
private fun validate(rows: List<ParsedCsvRow>):
ImportResult<List<ValidatedCsvRow>> =
rows.mapWithLineNumber { line, row ->
ValidatedCsvRow.from(
row,
listOf(
validateSingleFields(row, line),
validateFieldRelations(row, line),
)
)
}
.sequence()
入力形式のチェックを行う関数
成功値: チェック済みのCSV行リスト
失敗値: チェック過程のエラー情報リスト
validate:
28
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)
})
}
行の各フィールド
をチェックする関数
成功値: Unit (意味のある結果がないため)
失敗値: バリデーションエラー情報リスト
validateSingleFields: CSV
29
おわりに 標準ライブラリのResultは使いづらいがサードパー ティライブラリを導入するほどではないとき、必要 最小限のResult/Eitherを実装するのも選択肢になる 関数型言語で培われた設計パターンを参考にデータ 型や関数を整備することで、コードを宣言的な表現 に保ちやすくなる💪 30
Further Reading 標準ライブラリの Result の 標準ライブラリの Either 『 関数型デザイン&プログラミング』 サンプルコード: fpinscala/fpinscala 『なっとく!関数型プログラミング』 サンプルコード: miciek/grokkingfp-examples 『関数型ドメインモデリング』 Kotlin Arrow Either kotlin-result Scala Scala 31