Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save lagenorhynque/9bcf0340fb8b0499010cc17a188b1105 to your computer and use it in GitHub Desktop.

Select an option

Save lagenorhynque/9bcf0340fb8b0499010cc17a188b1105 to your computer and use it in GitHub Desktop.

Kotlinでミニマルな

Result実装による

関数型エラーハンドリング


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

Kotlin Fest 2025で登壇しました!

functional calisthenics in kotlin

Functional Calisthenics in Kotlin: Kotlinで「関数型エクササイズ」を実践しよう


  1. きっかけ

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

  3. Kotlinの Result/Either

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


1. きっかけ


取り組んでいたタスク

  • 取引(入出金)情報のCSVファイルについて形式/内容を検証してからDB保存するインポート機能

  • 想定する処理の流れ

    1. CSVファイルのパース
    2. 入力形式のチェック
    3. DBの既存情報との照合/補完
    4. DBへの保存

設計時に重視したこと

  • CSVファイルを取り込む複数の段階で様々な理由で失敗(エラー終了)する可能性がある

    • → どこでどのように失敗したか把握したい
  • エラーハンドリングを愚直に実装すると扱いづらく読みづらくなりそう

    • → コードをなるべく宣言的で高レベルな表現に保ちたい

⇒ 関数型言語で定番の Either/Result 型がほしい


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


A. 例外のthrow/catchによるエラーハンドリング

  • 主流言語でたいてい基本的な手法

  • 組み合わせづらい(composabilityが低い)

  • 例外のthrowとは大域脱出(readabilityも低い)

    • 良くも悪くも命令型の仕組みといえる
  • エラー情報を取り回すのにはあまり適していない

  • エラーが回復不能/不要で暗黙的に扱えば十分な場合には使いやすい


B. エラーを表す型によるエラーハンドリング

  • (静的型付き)関数型言語でよくある設計パターン

    • Either: Haskell, Scala, etc.

    • Result: OCaml, Elm, Rust, etc.

  • ただの(ふさわしい型が付いた)値なので:

    • 自由に組み合わせられる

    • 便利な関数などを用意して操作を抽象化できる

  • エラーを明示的に扱いたい場合にほしくなる


3. Kotlinの Result/Either


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  // 到達しない
  • 成功値 Success or 失敗値 Failure (Throwable)
    • 関数型言語でいう Either/Result ほど汎用的ではない

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!")
  • 成功値 Right or 失敗値 Left
  • either メソッドによってフラットに命令的に記述できる

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!")
  • 成功値 Ok or 失敗値 Err
  • binding メソッドによってフラットに命令的に記述できる

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などのメソッドの連鎖に対する汎用的なシンタックスシュガー)が便利

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


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
    // 以下、便利な関数を定義(後述)
}

ImportError 型: インポートに関するエラー情報

data class ImportError(
    val line: Int,
    val message: String,
)
  • ImportResult.Success<T>: T 型の成功値

  • ImportResult.Failure: List<ImportError> の失敗値

    • 内容は ImportError リストに固定した(汎用的な実装ではなく用途特化で扱いやすくするため)

ImportResult インターフェースの便利な関数

fun <U> fold(valueFn: (T) -> U, errorsFn: (List<ImportError>)
  -> U): U =
    when (this) {
        is Success -> valueFn(value)
        is Failure -> errorsFn(errors)
    }
  • fold関数: 成功値、失敗値それぞれに関数適用して同じ型の値に畳み込む
    • cf. Listに対するfold関数

      • 概念的には、空(nil)もしくは要素を持つ(cons)再帰的な代数的データ型
    • 典型的な操作でwhenによる場合分けが不要に


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

fun <U> flatMap(f: (T) -> ImportResult<U>): ImportResult<U> =
    fold(
        { f(it) },
        { Failure(it) },
    )
  • flatMap関数: 成功値に ImportResult を返す関数を適用する

fun <U> map(f: (T) -> U): ImportResult<U> =
    flatMap { Success(f(it)) }
  • map関数: 成功値に関数を適用する
    • 失敗値はそのまま

    • cf. Listに対するmap関数

    • ℹ️ HaskellではFunctor型クラスの fmap

    • flatMapがあればmapは実装できる

      • ℹ️ なぜなら、Monad ⇒ Functor

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) },
        ) },
    )
  }
  • sequence関数: List<ImportResult<A>>ImportResult<List<A>> に変換する
    • 失敗値があれば ImportError リストに集約

利用例: 取引の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) }
  • import: メインの関数
    • 成功値: インポートした取引データのリスト
    • 失敗値: パース、チェック、DBデータ補完、保存のいずれかの段階で発生したエラー情報リスト

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()
  }
  • parse: CSVファイルのパース処理を行う関数
    • 成功値: パース済みのCSV行リスト
    • 失敗値: パース過程でのエラー情報リスト

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: 必須の列を抽出する関数
    • 成功値: 抽出された文字列
    • 失敗値: 抽出過程のエラー情報リスト

private fun extractColumn(
    columnName: String,
    rowContext: RowContext,
): ImportResult<String?> =
    rowContext.row[columnName]?.trim()
        .let { ImportResult.Success(it) }
  • extractColumn: 列を抽出する関数
    • 成功値: 抽出された文字列
    • 失敗値: なし

private fun validate(rows: List<ParsedCsvRow>):
  ImportResult<List<ValidatedCsvRow>> =
    rows.mapWithLineNumber { line, row ->
        ValidatedCsvRow.from(
            row,
            listOf(
                validateSingleFields(row, line),
                validateFieldRelations(row, line),
            )
        )
    }
        .sequence()
  • validate: 入力形式のチェックを行う関数
    • 成功値: チェック済みのCSV行リスト
    • 失敗値: チェック過程のエラー情報リスト

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)
        })
    }
  • validateSingleFields: CSV行の各フィールドをチェックする関数
    • 成功値: Unit (意味のある結果がないため)
    • 失敗値: バリデーションエラー情報リスト

おわりに

  • 標準ライブラリのResultは使いづらいがサードパーティライブラリを導入するほどではないとき、必要最小限のResult/Eitherを実装するのも選択肢になる

  • 関数型言語で培われた設計パターンを参考にデータ型や関数を整備することで、コードを宣言的な表現に保ちやすくなる💪


Further Reading

/* 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 // 到達しない
/* 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("mean of empty list!")
x + y // => Left("mean of empty list!")
}
/* 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("mean of empty list!")
x + y
} // => Err("mean of empty list!")
slides:
charset: utf-8
theme: night
highlight_theme: monokai-sublime
separator_vertical: ^\s*----\s*$
revealjs:
transition: convex
plugins:
- extra_css:
- https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css
/* 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!")
#!/usr/bin/env bash
# pip install mkslides
open http://localhost:8000 \
&& mkslides serve *.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment