kotlin-result入門

はじめに

前回の記事でも触れたとおり、現在進行中のKotlinのバックエンド移行計画では、kotlin-resultを使ったResult型によるエラーハンドリングを採用しています。

今回は、Result型を使うことの利点や、標準ライブラリと比較したときの kotlin-result の利点について紹介したいと思います。

Result型の特徴は、言語や実装によらずだいたい共通しているので、他言語のユーザーでも、Result型を使ってみたい人の参考になれば嬉しいです。

github.com

Result型の定義とサンプルコード

Result型は、ある処理の成功時の結果を表す型と失敗時の結果を表す型の直和の形式で組み合わせた型です。

Kotlinでは、sealed type を使って、次のような形で定義することができます。

sealed interface Result<T, E>

data class Ok<T>(val value: T): Result<T, Nothing>

data class Err<E>(val error: E): Result<Nothing, E>

まず、Result型を使ったコードの雰囲気をつかむために、「指定したidのユーザーが有料会員だった場合はメールを送り、無料会員だったらエラーにする」というユースケースを、Result型を使う場合と使わない場合でそれぞれ書いてみます。

Result型を使わない場合

fun mainTask(userId: Int) {
    val user = findUser(userId)
    if (user == null) {
        println("Failure: User is not found.")
        return
    }

    val mailText = try {
        writeMailTextToPaidUser(user)
    } catch(e: MailTargetException) {
        println("Failure: ${e.message}")
        return
    }

    try {
        sendMail(mailText)
    } catch(e: MailSendingException) {
        println("Failure: ${e.message}")
        return
    }

    println("Success!!")
}

data class User(val id: Int, val name: String, val status: PaymentStatus) {
    enum class PaymentStatus {
        PAID_USER,
        FREE_USER;
    }
}

fun findUser(userId: Int): User? {
    val userList = listOf(
        User(id = 1, name = "Ally", status = User.PaymentStatus.FREE_USER),
        User(id = 2, name = "Bob", status = User.PaymentStatus.PAID_USER),
        User(id = 3, name = "Charles", status = User.PaymentStatus.FREE_USER),
        User(id = 4, name = "Daniel", status = User.PaymentStatus.PAID_USER),
    )
    return userList.find { it.id == userId }
}

fun writeMailTextToPaidUser(user: User): String {
    return when (user.status) {
        User.PaymentStatus.PAID_USER -> "We have good information for paid users!!"
        User.PaymentStatus.FREE_USER -> throw MailTargetException("Sorry, this information is only for paid users.")
    }
}

class MailTargetException(msg: String): Exception(msg)

fun sendMail(text: String) {
    try {
        // some sending processes
    } catch (e: Exception) {
        throw MailSendingException("failed to send a mail. Reason: $e")
    }
}

class MailSendingException(msg: String): Exception(msg)

Result型を使う場合

import com.github.michaelbull.result.Result
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import com.github.michaelbull.result.toResultOr

fun mainTask(userId: Int) {
    findUser(userId)
        .andThen { user -> writeMailTextToPaidUser(user) }
        .andThen { mailText -> sendMail(mailText) }
        .onSuccess { println("Success!!") }
        .onFailure { msg -> println("Failure: $msg.") }
}

data class User(val id: Int, val name: String, val status: PaymentStatus) {
    enum class PaymentStatus {
        PAID_USER,
        FREE_USER;
    }
}

fun findUser(userId: Int): Result<User, String> {
    val userList = listOf(
        User(id = 1, name = "Ally", status = User.PaymentStatus.FREE_USER),
        User(id = 2, name = "Bob", status = User.PaymentStatus.PAID_USER),
        User(id = 3, name = "Charles", status = User.PaymentStatus.FREE_USER),
        User(id = 4, name = "Daniel", status = User.PaymentStatus.PAID_USER),
    )
    return userList.find { it.id == userId }
        .toResultOr { "User is not found." }
}

fun writeMailTextToPaidUser(user: User): Result<String, String> {
    return when (user.status) {
        User.PaymentStatus.PAID_USER -> Ok("We have good information for paid users!!")
        User.PaymentStatus.FREE_USER -> Err("Sorry, this information is only for paid users.")
    }
}

fun sendMail(text: String): Result<Unit, String> {
    return runCatching {
        // some sending processes
    }.mapError { e -> "failed to send a mail. Reason: ${e.message}" }
}

なぜResult型を使うのか

エラー処理の手段として例外クラスとResult型を比べたとき、Result型には次のようなメリットがあります。

関数シグネチャで、呼び出し元が処理すべきエラーを伝えることができる

Kotlinでは検査例外の仕組みを持たないため、ある関数がどのようなエラーを投げるのか、シグネチャからは読み取ることができません。

@throws のようなdocコメントを書く慣例はありますが、ネストされた全ての関数が投げるかもしれない例外を過不足なく挙げるのは現実的ではなく、単に @throws DatabaseException のような原因不明の曖昧な型が書かれていたり、docコメントの記載自体が無いことも多いです。

また、検査例外がある言語でもシグネチャに throws Exception と書かれてしまうと、エラーのケース分けを適切に行うためにはその関数の実装を深いところまで追う必要があり、辛いです。

Result型を使う場合は、戻り値の型によってエラーの可能性が明示され、エラー処理に必要な情報は型の中に全て含まれています。

呼び出し元にエラー処理を強制できる

検査例外が有る言語でも無い言語でも例外を使ったエラー処理では、呼び出し側は、「例外に対して何もしない」という選択肢があります。 これは、意図的に選ばずとも、呼び出し側が例外の存在を失念した場合にも起きてしまいます。

「何もしない」ことを選ばれた例外は、さらに上の呼び出し元にそのまま投げられるため、それらが上流で合流し、結果として前述の throws Exceptions のような曖昧な例外を投げる関数が誕生するのです。

しかし、Result型では値を取り出すためには、成功時の型ではなくResult型のメソッドを呼ぶ必要があり、そこで必ずエラー処理について意識させられます。 これによって、全てのエラーが適切なレイヤーで処理されることになり、あまりに広範囲なエラーケースを持つ関数の誕生を防ぐことができます。

import com.github.michaelbull.result.*
import kotlin.random.Random

// エラーを例外で返す関数
fun rollDieOrThrow(): Int {
    val n = Random.nextInt(1, 6)
    return if (n != 1) {
        n
    } else {
        throw RuntimeException("Oops, 1 is rolled.")
    }
}

val a: Int = rollDieOrThrow() // 例外によるエラーは考慮しなくても値を取得できる


// エラーをResult型で返す関数
fun rollDieOrError(): Result<Int, String> {
    val n = Random.nextInt(1, 6)
    return if (n != 1) {
        Ok(n)
    } else {
        Err("Oops, 1 is rolled.")
    }
}

// val b: Int = rollDieOrError() // 戻り値はInt型ではないので、コンパイルエラー
// 以下のような選択肢から、エラー処理方法を選ぶ必要がある

val c: Int? = rollDieOrError().get() // 失敗したらnullにする

val d: Int = rollDieOrError().unwrap() // 失敗しないことを前提に、失敗したらUnwrapExceptionを投げる

val e: Int = rollDieOrError().getOr(1) // 失敗したらデフォルト値を返す

// 成功時と失敗時の処理をセットで定義する
when(val result = rollDieOrError()) {
    is Ok -> println("${result.value} is rolled!!")
    is Err -> println("You are unlucky.")
}

復帰可能なエラーと復帰不可能なエラーを明確に区別できる

GoやRustのような例外機構を持たない言語では、復帰可能な通常のエラーは関数の戻り値で返し、復帰不可能な致命的なエラーは panic() で処理を強制終了させます。

同様な使い分けをResult型と例外クラスで行うことで、呼び出し元が考慮するべきエラーと、発生したら諦める or コードを直すエラーを区別することができます。

この場合、例外が発生するケースは個別の処理を行う必要がないので、RuntimeException のような一般的な例外クラスを投げても問題がありません。

import com.github.michaelbull.result.*

// 未定義変数が含まれます

/**
 * [url]に対してGETリクエストを送り、http bodyを返す。
 * [url]のパターンチェックは呼び出し元の責務とする。
 */
fun getContents(url: String): Result<String, String> {

    if (!urlRegex.matches(url)) {
        throw IllegalArgumentException("$url is not URL") // パターンチェックは呼び出し元の責務なので、違反は例外にする (防御的プログラミング)
    }

    val response = httpClient.get(url)
    val statusCodeStr = response.statusCode.toString()

    if (statusCodeStr.startWith("4")) {
       return Err(response.body) // 400系のエラーは内容をユーザーに返したいのでResult型で返す
    }
    if (statusCodeStr.startWith("5")) {
       throw RuntimeException(response.body) // 500系のエラーはどうしようもないので例外を投げる
    }
    return response.body
}

エラー処理を簡潔に書ける

「ある処理を行い、それに成功したら次の処理を行う」という繰り返しはプログラミングの基本的なパターンの1つです。

例外によるエラー処理でこれをやろうとすると、都度 try ... catch のブロックを作ってそれぞれの結果を変数に入れて、catchの度に早期リターンや例外の投げ直しをする必要があります。 しかし、Result型を使えば一連の流れを1つのメソッドチェーンで簡潔に書けることが多いです。

両者の違いは、上記2つのサンプルコードを見比べたときに、一番強く感じる違いかと思います。

ただし、メソッドチェーンを使った書き方はやりすぎると、かえって可読性を落とすことになったり、変数スコープの管理が難しくなったりするので注意が必要です。

import com.github.michaelbull.result.*

// 未定義のクラスや関数が含まれます

// これを
fun someProcess(): ReturnValueC {
    val returnValueA = try {
        doA()
    } catch(e: ExceptionA) {
       throw ExceptionWrapper(e) 
    }
    val returnValueB = try {
        doB(returnValueA)
    } catch(e: ExceptionB) {
        throw ExceptionWrapper(e) 
    }
    return try {
        doC(returnValueB)
    } catch(e: ExceptionC) {
        throw ExceptionWrapper(e) 
    }
}

// こう書ける
fun someProcessWithResult(): Result<ReturnValueC, ErrorWrapper> {
    return doAWithResult()
      .andThen { doBWithResult(it) }
      .andThen { doCWithResult(it) }
      .mapError { ErrorWrapper(it) }
}

なぜkotlin-resultを使うのか

kotlin-result が作られた背景として、当時のKotlin標準ライブラリではResult型はすでに存在していたものの、関数の戻り値として使用できない仕様だったという事情がありました。

ただ、その制限もKotlin 1.5で撤廃されたので、現在あえて標準ライブラリではなく3rd partyのものを使う理由は、一見ないように思えます。

しかし、kotlin-resultには大きく以下の2点の強みがあるので、Result型で快適なプログラミングを行うためには、kotlin-resultをぜひ採用するべきだと考えます。

メソッドが豊富に用意されている

kotlin-resultでは他の言語のResult型を参考にして、多くのメソッドが実装されていますが、それに比べると標準ライブラリの実装メソッドは最小限に限定されています。

標準ライブラリでは成功とエラーで完全な対称的な作りになっておらず、多くの処理では成功や失敗のどちらかにしかないメソッドが用意されていません。

kotlinlang.org

特に andThen() (または flatMap()) が無いため、前述のようなメソッドチェーンを使った書き方できれいに書けないのが不便です。

もちろん、自分で拡張メソッドを定義することもできますが、よく使うメソッドは最初から用意されていたほうが便利です。

エラーの型を自由に設定できる

標準ライブラリのResult型は、型定義が Result<out T> となっており、エラーの場合を表す型が Exception に固定されています。

そのため例えば、単純にエラーメッセージを返したいときでも、わざわざ Exception("message") のように例外クラスで包む必要があります。

さらに大きな問題は、エラー側の定義はジェネリクスすら使われていないため、独自の例外クラスで追加されたプロパティやメソッドをエラー処理に利用できないことです。

class NotFoundException(val className: String, val id: Int): Exception()

// 標準ライブラリのResult型を返す関数
fun getKotlinError(): kotlin.Result<Int> {
    return kotlin.Result.failure(NotFoundException("User", 123))
}

val result = getKotlinError()

// Exception型のitに対してこうすることはできないので
// val id: Int = result.getOrElse { it.id }

// こうする必要がある
val id: Int = result.getOrElse { if (it is NotFoundError) it.id else error("redundant error because it never comes here") }

一方でkotlin-resultのResult型定義は Result<out T, out E> であり、エラー側に例外クラスに限らず自由な型を設定できるため、型を最大限利用しつつシンプルな形でエラー処理を行うことができます。

Result型を使ったエラーパターン

例外クラスに限らず好きな型を使ってエラーを表現できる kotlin-result のResult型ですが、ここではよく使うエラー表現のパターンを紹介したいと思います。

単純なエラーメッセージを返す

複雑なエラー処理が不要で、UIやログに表示するためのエラーメッセージだけが必要な場合のエラーは、Err<String> を返せば十分でしょう。 エラー用の型を新しく定義する必要がないので、手軽にエラーを返すことができます。

ちなみに、バリデーション関数などでエラーの有無だけが必要な場合は、関数の戻り値を String? にしてエラーでない場合は null を返すというパターンも考えられます。 しかし、そういう場合も Result<Unit, String> を戻り値にした方が、戻り値がエラーメッセージであることを型で主張することができ、他のエラー処理との組み合わせもしやすくなるためおすすめです。

import com.github.michaelbull.result.*

fun Int.shouldBeEven(): Result<Unit, String> {
    return if (this % 2  == 0) {
        Ok(Unit) 
    } else {
        Err("$this is not even number!!")
    }
}

構造化したエラー情報を返す

エラーからの復帰を試みるための何らかの処理を行いたい場合は、エラー情報を表すためのdata classを作成して、それをエラー時の型に指定します。

エラーを表すdata class は基本的にエラーメッセージを返すためのメソッドも定義しておいた方が、ログなどの際に便利でしょう。

import com.github.michaelbull.result.*

data class UserAccount(val accountId: Int, val userId: Int, val data: String)

data class AccountDuplication(val accountId: Int, val userId: Int) {
    fun errorMessage(): String = "user <$userId> has already created another account. old account id is $accountId" 
}

fun createAccount(userId: Int, data: String): Result<UserAccount, AccountDuplication> { ... }

fun updateAccount(accountId: Int, data: String): UserAccount { ... }


val userId = 123
val data = "some data"
val saveData = createAccount(userId, data)
  .orElse { err ->
      println(err.message())
      println("update account <$err.accountId> instead")
      updateAccount(err.accountId, data) 
  }

複数のエラー情報を同時に返す

エラー時の型にListを指定すれば、複数のエラーを同時に返すことができます。

リクエストパラメータのバリデーションなどでエラーの発生個所を明示したい場合は、エラー時の型を Err<Pair<String,String>> のような形に変換して、最終的に List<Pair<String, String>> の形で集計します。 Map型は同じ項目に対して複数のエラーを持たせることができず、表示の順番も制御できないためあまり使いません。

同じことを例外を使ってやろうとした場合、例外は発生した場所でプログラムの進行が止まってしまうため、複数のエラーを同時に変えそうとすると、項目ごとに try ... catch を書いてなかなか複雑なコードになってしまいます。 複数のエラーの同時制御は、Result型の強みが生かせるユースケースの1つです。

import com.github.michaelbull.result.*

data class UserProfile(
    val firstName: String, 
    val lastName: String, 
    val postalCode: String, 
    val address: String,
)

fun String.shouldNotBeEmpty(): Result<Unit, String> {
    return if (this.isNotEmpty) {
        Ok(Unit)
    } else {
        Err("An empty string is not allowed")
    }
}

/** 複数エラーをまとめて返す関数 */
fun validateProfile(profile: UserProfile): Result<Unit, List<Pair<String, String>>> {
    val results = listOf(
        profile.firstName.shouldNotBeEmpty()
            .mapError { msg -> "firstName" to msg }
        profile.lastName.shouldNotBeEmpty()
            .mapError { msg -> "lastName" to msg }
        profile.postalCode.shouldNotBeEmpty()
            .mapError { msg -> "postalCode" to msg }
        profile.address.shouldNotBeEmpty()
            .mapError { msg -> "address" to msg }
    )

    val errors = results.getAllErrors()
    return if (errors.isEmpty()) {
        Ok(Unit)
    } else {
        Err(errors)
    }
}

型による分岐が可能なエラーを返す

エラー時の型をenumやsealed classにすれば、whenを使った網羅的なエラー処理をコンパイラに保証させることができます。

import com.github.michaelbull.result.*

enum class AuthorizationError {
    NOT_LOGIN,
    PAYMENT_REQUIRED,
    FORBIDDEN;
}

fun authorizeYou(): Result<Unit, AuthorizationError> { ... }

authorizeYou()
    .onSuccess { println("OK, you are authorized") }
    .onFailure { errEnum ->
        when(errorEnum) {
            AuthorizationError.NOT_LOGIN ->
                println("Please go to login page.")
            AuthorizationError.PAYMENT_REQUIRED ->
                println("This page requires payment.")
            AuthorizationError.FORBIDDEN ->
                println("Sorry, this page is not for you.")
        }
    }

kotlin-resultの基本メソッド

kotlin-resultには多くのメソッドが用意されていますが、とりあえずこの 5 * 2 種類のメソッドさえ覚えておけば、やりたいことはだいたい実現できるかと思います。

表記の説明 - 「some / another」 という形式の表記は、左が成功時のメソッド名や型、右がエラー時のメソッド名の型を表します。 - また、説明は Result<T, E> の型を前提とし、T は成功時に返す型を、E はエラー時に返す型を表します。

get() / getError()

引数: なし

戻り値: T? / E?

成功時と失敗時、それぞれの値をそのまま返します。 失敗したResultに対して get() を呼んだ場合や成功したResultに対して getError() を呼んだ場合はnullが返ります。

unwrap() / unwrapError()

引数: なし

戻り値: T / E

成功時と失敗時、それぞれの値をそのまま返します。 失敗したResultに対して unwrap() を呼んだ場合や成功したResultに対して unwrapError() を呼んだ場合は、 UnwrapException が投げられます。

nullableな値に対する !! 演算子のように、あらかじめ成功・失敗がわかっているResultから値を取り出したい場合に利用します。

onSuccess() / onFailure()

引数: (T) -> Unit / (E) -> Unit

戻り値: Result<T, E> / Result<T, E>

成功時と失敗時、それぞれの場合に引数で与えたラムダを実行します。 戻り値は常にレシーバーとなるResultの値をそのまま返します。

主なユースケースとしては、 ログへの書き込みの実行や、onSuccess() で結果をDBに保存したり onFailure() で早期リターンしたりする使い方が考えられます。

map() / mapError()

引数: (T) -> U / (E) -> F

戻り値: Result<U, E> / Result<T, F>

成功時と失敗時、それぞれの返す値を引数のラムダで変換します。 失敗したResultに対して map() を呼んだ場合や成功したResultに対して mapError() を呼んだ場合はラムダが実行されず、レシーバーとなるResultの値をそのまま返します。

成功時と失敗時、それぞれで返す値の型を変更することはできますが、成功を失敗にしたり失敗を成功にしたりすることはできません。

import com.github.michaelbull.result.*

Ok(1).map { "$it is positive" }
// => Ok("1 is positive")

Err(-1).map { "$it is positive" }
// => Err(-1)

Ok(1).mapError { "$it is negative" }
// => Ok(1)

Err(-1).mapError { "$it is negative" }
// => Err("-1 is negative")

andThen() / orElse()

引数: (T) -> Result<T, E> / (E) -> Result<T, E>

戻り値: Result<T, E> / Result<T, E>

成功時と失敗時、それぞれの場合に引数で与えたラムダを実行し、その結果を返します。 失敗したResultに対して andThen() を呼んだ場合や成功したResultに対して orElse() を呼んだ場合はラムダが実行されず、レシーバーとなるResultの値をそのまま返します。

成功を失敗にしたり失敗を成功にしたりすることはできますが、成功時と失敗時、それぞれで返す値の型を変更することはできません。

全く同じ内容のメソッドとして flatMap() があります。

import com.github.michaelbull.result.*

Ok("Succeeded at first")
    .andThen { Ok("$it and succeeded again") }
    .orElse { Ok("$it but succeeded finally") } // 前の結果がOkなので、ここは実行されない
// => Ok("Succeeded at first and succeeded again")

Err("Failed at first")
    .andThen { Ok("$it and succeeded again") } // 前の結果がErrなので、ここは実行されない
    .orElse { Ok("$it but succeeded finally") }
// => Ok("Failed at first and succeeded again")

Ok("Succeeded at first")
    .andThen { Ok("$it and succeeded again") }
    .andThen { Err("$it but failed finally") }
// => Err("Succeeded at first and succeeded again but failed finally")

おわりに

今回は、Kotlinの多機能なResult型ライブラリ kotlin-result を紹介しました。

Result型を導入して、型を最大限利用したエラーハンドリングを始めてみましょう!

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

特にバックエンドチームでは、Kotlinサーバー導入に携わるメンバーを募集強化中です!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com