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

-- Views

November 25, 25

スライド概要

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

profile-image

「楽しく楽にcoolにsmartに」を理想とするprogrammer/philosopher/liberalist/realist。 好きな言語はClojure, Haskell, Elixir, English, français, русский。 読書、プログラミング、語学、法学、数学が大好き! イルカと海も大好き🐬

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

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

2.

🐬カマイルカ 株式会社スマートラウンドのシニアエンジニア スタートアップと投資家のやり取りを効率化する データ管理プラットフォームを開発している 技術スタック: Kotlin/Ktor & TypeScript/Vue.js Server-Side Kotlin Meetupの運営にも協力 Clojure, Haskellなどの関数型言語の愛好者 関数型まつりの運営スタッフ(座長のひとり) Java, Scala, Clojure, KotlinとJVM言語での開発経験 Kotlinの実務利用は1年半ほど🐣 lagénorhynque 2

3.

で登壇しました! Kotlin Fest 2025 で「関数型エクササイズ」を実践しよう Functional Calisthenics in Kotlin: Kotlin 3

4.

きっかけ エラーハンドリングのパターン の Result/Either 4. 実際に採用したResult実装 1. 2. 3. Kotlin 4

5.

1. きっかけ 5

6.

取り組んでいたタスク 取引(入出金)情報のCSVファイルについて形式/内容 を検証してからDB保存するインポート機能 想定する処理の流れ 1. CSVファイルのパース 2. 入力形式のチェック 3. DBの既存情報との照合/補完 4. DBへの保存 6

7.

設計時に重視したこと CSVファイルを取り込む複数の段階で様々な理由で 失敗(エラー終了)する可能性がある → どこでどのように失敗したか把握したい エラーハンドリングを愚直に実装すると扱いづらく 読みづらくなりそう → コードをなるべく宣言的で高レベルな表現に 保ちたい ⇒ 関数型言語で定番の Either/Result 型がほしい 7

8.

2. エラーハンドリングのパターン 8

9.

例外のthrow/catchによるエラーハンドリング 主流言語でたいてい基本的な手法 組み合わせづらい(composabilityが低い) 例外のthrowとは大域脱出(readabilityも低い) 良くも悪くも命令型の仕組みといえる エラー情報を取り回すのにはあまり適していない エラーが回復不能/不要で暗黙的に扱えば十分な場 合には使いやすい A. 9

10.

エラーを表す型によるエラーハンドリング (静的型付き)関数型言語でよくある設計パターン B. Either: Haskell, Scala, etc. Result: OCaml, Elm, Rust, etc. ただの(ふさわしい型が付いた)値なので: 自由に組み合わせられる 便利な関数などを用意して操作を抽象化できる エラーを明示的に扱いたい場合にほしくなる 10

11.

の Result/Either 3. Kotlin 11

12.
[beta]
標準ライブラリの 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

13.
[beta]
の 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

14.
[beta]
の 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

15.
[beta]
参考] 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

16.

4. 実際に採用したResult実装 16

17.
[beta]
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

18.

ImportError 型: インポートに関するエラー情報 data class ImportError( val line: Int, val message: String, ) ImportResult.Success<T>: T 型の成功値 ImportResult.Failure: List<ImportError> の失敗値 内容は ImportError リストに固定した(汎用的 な実装ではなく用途特化で扱いやすくするため) 18

19.
[beta]
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

20.

fun getValueOrThrow(): T = this.fold( { it }, { throw IllegalStateException( "ImportResult is Failure: $it" ) }, ) 関数: 成功値の中身を取り出す 失敗値に対しては動作しない get 20

21.
[beta]
fun <U> flatMap(f: (T) -> ImportResult<U>): ImportResult<U> =
fold(
{ f(it) },
{ Failure(it) },
)

関数: 成功値に ImportResult を返す関数
を適用する
失敗値はそのまま
cf. Listに対するflatMap関数
ℹ️ HaskellではMonad型クラスの >>= (bind)
flatMap

21

22.
[beta]
fun <U> map(f: (T) -> U): ImportResult<U> =
flatMap { Success(f(it)) }

関数: 成功値に関数を適用する
失敗値はそのまま
cf. Listに対するmap関数
ℹ️ HaskellではFunctor型クラスの fmap
flatMapがあればmapは実装できる
ℹ️ なぜなら、Monad ⇒ Functor

map

22

23.
[beta]
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

24.
[beta]
利用例: 取引の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

25.
[beta]
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

26.
[beta]
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

27.
[beta]
private fun extractColumn(
columnName: String,
rowContext: RowContext,
): ImportResult<String?> =
rowContext.row[columnName]?.trim()
.let { ImportResult.Success(it) }

列を抽出する関数
成功値: 抽出された文字列
失敗値: なし

extractColumn:

27

28.
[beta]
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

29.
[beta]
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

30.

おわりに 標準ライブラリのResultは使いづらいがサードパー ティライブラリを導入するほどではないとき、必要 最小限のResult/Eitherを実装するのも選択肢になる 関数型言語で培われた設計パターンを参考にデータ 型や関数を整備することで、コードを宣言的な表現 に保ちやすくなる💪 30

31.

Further Reading 標準ライブラリの Result の 標準ライブラリの Either 『 関数型デザイン&プログラミング』 サンプルコード: fpinscala/fpinscala 『なっとく!関数型プログラミング』 サンプルコード: miciek/grokkingfp-examples 『関数型ドメインモデリング』 Kotlin Arrow Either kotlin-result Scala Scala 31