kotlin-resultを半年使ってみて
はじめに
ログラスの小林(@mako-makok)です。
この記事は毎週必ず記事がでるテックブログ "Loglass Tech Blog Sprint" の 4 週目 の記事です!
1 年間連続達成まで 残り 49 週 となりました!
Kotlin でのエラーハンドリングの改善に向けて、kotlin-result
というライブラリを導入したのですが、使い始めて約半年経過しました。
今回は使ってみて実際にどうだったかを振り返ってみます。
What kotlin-result
Rust のResult
や、Scala の Either
など、関数型の概念を取り入れた言語には例外ではなく、失敗する可能性のある処理は成功と失敗の型をシグネチャで表現できるようになっています。
kotlin-result
は それらの表現を Kotlin でも利用できるようにしたライブラリです。
内部の実装を見てみるとそれらは代数的データ型で実装されています。
public sealed class Result<out V, out E> { ... }
public class Ok<out V>(public val value: V) : Result<V, Nothing>() { ... }
public class Err<out E>(public val error: E) : Result<Nothing, E>() { ... }
Ok
は成功値、 Err
失敗値を表すクラスで、それぞれ Result
を継承しています。
利用側は Result
のインタフェースを使用し、Ok
か Err
の型を確定させないとそれぞれ値を取り出すことができません。
enum class ErrorCode {
ZERO_DIVISION_ERROR
}
fun div(a: Int, dividedBy: Int): Result<Int, ErrorCode> {
return if (dividedBy == 0) {
return Err(ErrorCode.ZERO_DIVISION_ERROR)
} else {
Ok(a / dividedBy)
}
}
fun main() {
when (val result = div(10, 0)) {
is Ok -> println(result.value)
is Err -> throw ArithmeticException()
}
}
これが Result
の基本になっており、koltin-result
では Result
と、それらに付随する拡張関数が多数提供されています。
// findの結果がnullであれば NotFoundException に変換する
userRepository.find().toResultOr(::NotFoundException)
val value = div(10, 0)
// 成功値のIntをBigDecimalに変換する
.map { it.toBigDecimal() }
// エラーコードを例外に変換する
.mapError {
when (it) {
ErrorCode.ZERO_DIVISION_ERROR -> ArithmeticException()
}
}
// ResultがOkであれば値を取り出し、ErrであればThrowableをthrowする
.getOrThrow()
他にも便利な API がありますので、ぜひ使ってみてください。
より具体の話はこちらの記事がとても良くまとまっており、大変参考にさせていただきました。
そもそもなぜ Kotlin にこのような代数的に処理できる機構が必要なのか、については以下のスライドをご覧ください。
Kotlin における型の世界と エラーハンドリング / Type World and Error Handling in Kotlin
Kotlin 標準の Result について
Kotlin には標準で Result が付属しています。
Kotlin 1.5 以前は戻り値に指定できないという制約があったのですが、それ以降のバージョンでは普通に利用できます。
標準のResult
は似たようなことができるのですが、いくつかの問題があります。
fun div(a: Int, dividedBy: Int): Result<Int> {
// 戻り値がkotlin.Resultは成功値の型の指定しかできないので、失敗値の型が消失する
return if (dividedBy == 0) {
// failureにはThrowableしか投入することができない
return Result.failure(ArithmeticException())
} else {
Result.success(a / dividedBy)
}
}
fun main() {
val value = div(10, 0)
.map { it.toBigDecimal() }
.getOrThrow()
}
実際 kotlin-result をプロダクションコードで利用してみて
新規開発部分は積極的に Result
を利用するようにしています。
基本パターンマッチ前提のコードベースになるので、定性的にはなりますがエラーのハンドリングやパターンの組み合わせの不備によって発生する不毛なバグは減ったように感じます。
そしてこれが一番大きなところですが、一括更新系の機能で複数行のエラーを出すことがかなり容易になりました。
Loglass のプロダクトの性質上、ファイルアップロードによるデータ追加/更新がかなり多いです。
また、扱うデータ量も多く、間違っている行が複数ある場合に著しく UX を損ねる結果にもなるので、自前実装で機構を作らなくてもサクッと作れるのはとても良かったです。
下記のコードは一例です。
data class UserName(
val value: String,
) {
companion object {
fun of(value: String): Result<UserName, IllegalArgumentException> {
return value.let {
if (it.isBlank()) {
Err(IllegalArgumentException("ユーザー名は必須です"))
} else {
Ok(it)
}
}.andThen {
if (it.length > 20) {
Err(IllegalArgumentException("ユーザー名は20文字以内で入力してください"))
} else {
Ok(it)
}
}.map {
UserName(it)
}
}
}
}
data class UserCode(
val value: String,
) {
companion object {
fun of(value: String): Result<UserCode, IllegalArgumentException> {
return value.let {
if (it.isBlank()) {
Err(IllegalArgumentException("コードは必須です"))
} else {
Ok(it)
}
}.andThen {
if (it.length > 20) {
Err(IllegalArgumentException("コードは20文字以内で入力してください"))
} else {
Ok(it)
}
}.map {
UserCode(it)
}
}
}
}
data class User(
val name: UserName,
val code: UserCode,
)
data class Row(
val userName: String,
val code: String,
)
fun List<Row>.toUsers(): List<Result<User, List<IllegalArgumentException>>> {
return this.map { row ->
val userNameResult = UserName.of(row.userName)
val userCodeResult = UserCode.of(row.code)
zipOrAccumlate(
{ userNameResult },
{ userCodeResult },
{ userName, userCode -> User(userName, userCode) }
)
}
}
fun main(rows: List<Row>) {
rows
.toUsers()
.partition()
.let { (oks, errs) ->
if (errs.isNotEmpty()) {
throw IllegalArgumentException(
errs
.flatten()
.map { it.message }
.joinToString("\n")
)
} else {
userRepository.save(oks)
}
}
}
これらは同じチームの ゆいとさんがより詳しく書かれたスライドもありますので、こちらを御覧ください。
B2B SaaS あるある! 一括処理のエラーハンドリングを Kotlin で関数型的に処理する / Kotlin Functional Multi Error Handling
kotlin-result 以外の選択肢
kotlin-result
以外の選択肢としてはArrowが挙げられます。
Arrow
はより関数型の思想を反映したライブラリとなっており、arrow-core
やarrow-core-retrofit
のような複数のモジュールがあります。
Either
やNel
など便利なクラスやコレクションを内包したarrow-core
、ほかは retrofit
など様々なユースケースに対応したモジュールとなっています。
Arrow
は多様なユースケースを叶えられる厚いライブラリであり、選定をする際非常に悩みましたが、下記の理由で kotlin-result
を選択しました。
- Result(Either)以外の機構も入ってくるため、学習コストが高すぎると判断した
- 万が一 Arrow へ移行することになっても、
typealias
による補完と機械的な置換で済みそう
大前提、kotlin-result
の貢献度合いは高く Kotlin 標準では成し得なかった体験をもたらしています。
一方で、Arrow が提供しているクラスや API はkotlin-result
より豊富です。
zipOrAccumlate
は複数の Result を取り、1 件でもエラーがあれば List にまとめてエラーを返却します。
kotlin-result
の場合は通常のList
ですが、Arrow の場合はエラーがNonEmptyList
というクラスで返却されます。
NonEmptyList
はArrow
が提供しているクラスで、必ず element が 1 つ以上あることが保証されているコレクションです。
エラーが 1 件でもあればエラーになるので、List の中身は必ず 1 つ以上あるはずです。
Arrow
ではそれが型で表現されています。
fun List<Row>.toUsers(): List<Result<User, NonEmptyList<IllegalArgumentException>>> {
return this.map { row ->
zipOrAccumlate(
{ UserName.of(row.userName) },
{ UserCode.of(row.code) },
{ userName, userCode -> User(userName, userCode) }
)
}
}
kotlin-result
はかなり API が豊富ですが、Arrow
はそれを上回る充実度です。
kotlin-result の pros/cons
ここまで kotlin-result
と標準のResult
、Arrow
との比較について記載しましたが、最後に pros/cons をつらつら書いて終わります。
pros
- エラーの型を明示的にできるため、メソッドの振る舞いをすべてシグネチャで表現できるようになる
- シグネチャで表現できるので、ハンドリングはパターンマッチになり、基本的にハンドリング漏れ/リカバリ処理の漏れがなくなる
- ライブラリが軽量
- 付属している拡張関数が豊富。流れるようなインタフェースでエラーハンドリング処理を書くことができ、開発体験が良い
cons
- Result や Either は関数型の概念になるため、一定学習コストは高くなってしまう
- 利用者に書き方の強制ができないため、throw がどうしても混在してしまう
- エラーハンドリングをリッチに行おうとするとどうしてもコードの見通しが悪くなってしまう
- (
Arrow
と比べて)API の豊富さには劣る
終わりに
身も蓋もないですが、ライブラリ選定はプロジェクトの状況によって変わります。
数ある中でも、kotlin-result
は少ない導入コストで、Kotlin の課題を解決する OSS だと私は感じました。
今後はkotlin-result
自体にも貢献していきたいです。
Discussion