コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

KotlinにおけるエラーハンドリングとArrow-ktのEither型

この記事は、コドモンAdvent Calendar 2025 24日目の記事です。

こんにちは!プロダクト開発部のjunです。12月は子どもの誕生日が2回とサンタ業務と正月休みがあり、山積みの謎の段ボールやポチ袋たちと仕事をする日々です。 これらのビッグイベントを滞りなく段取りするマネジメント力が求められています。

さて、昨月コドモンではKotlin Festのスポンサーをさせていただきました。その際にスポンサーブースに置かせていただいたアンケートボードで「エラーハンドリングをどのようにしているか?」という内容のアンケートを用意していました。このアンケートに関するお話のなかで、コドモンとしてはどうしているのかをお話させていただくこともあったので、本記事ではその詳細について深堀りしていきます。

Kotlin Festのブースで用意していたボード

言語標準でできること・できないこと

まず、Kotlinの言語としてのチェック例外やResult型などの、エラーハンドリングに関する設計を確認します。

Kotlinにはチェック例外がない

Javaにはチェック例外(checked exceptions)という仕組みがあり、メソッドが投げる可能性のある例外を throws 句で宣言し、呼び出し側に処理を強制できます。一方、Kotlinにはチェック例外がありません。すべての例外は非チェック例外(unchecked exceptions)として扱われます。

古い公式ドキュメント(※)では、この設計判断について以下のように説明されています:

Kotlin does not have checked exceptions. There are many reasons for this, but we will provide a simple example.

このドキュメントでは、JDKの Appendable インターフェースを例に挙げています。StringBuilder が実装するこのインターフェースは IOException をスローする可能性があると宣言されていますが、StringBuilder への追記でIOエラーが発生することは実質的にありません。それでもチェック例外の仕組み上、呼び出し側は無意味な try-catch を書くか、例外を握りつぶすことになります。

このような背景から、Kotlinではチェック例外を採用せず、例外をシグネチャに表現する手段として型システム(nullable型や sealed class)を活用する方針が取られています。本記事で紹介する Result 型や Either 型によるエラーハンドリングも、この設計思想の延長線上にあります。

現在の例外のドキュメントからは削除されていますが、あくまでドキュメント上なぜチェック例外を言語仕様として提供しないかの理由の説明が省かれているだけで、すべての例外をチェックされていないものとして扱うことを前提として置くようになっただけのように見えます

stdlibのResult型の目的と使い所

stdlibの Result<T> 型はKotlin 1.5で導入されたもので、成功時の結果値として型 T を保持するか、任意の Throwable 例外を伴う失敗状態を表すものです。つまり Result<T> は実質的に Result<T, Throwable> 相当であり、エラー型を指定できません。Scalaの経験者であれば、 Try[T] と同じだと想像がつくかもしれません。

おもにビジネスロジックの表現などにこういった型を使いたい場合に、この仕様に苦しさを感じる人も多いでしょう。

ここで、resultが導入された際のKEEP(Kotlin Evolution and Enhancement Process:言語仕様の提案・議論の場)をみると言語仕様としては以下のように記載されています。

Kotlin encourages writing code in a functional style. It works well as long as business-specific failures are represented with nullable types or sealed class hierarchies, while other kinds of failures (that are represented by exceptions) do not require any special local handling. However, when interfacing with Java-style APIs that rely heavily on exceptions or otherwise having a need to somehow process exceptions locally (as opposed to propagating them up the call stack), we see a clear lack of primitives in the Kotlin standard library.

これを読む限り ビジネスロジックの戻り値の型としては sealed を使うべきであり、例外を多用する場合のハンドリングが必要な場合に Result<T> を用いるべき と解釈できます。つまり、前述の「ビジネスロジック上のエラーを仕様として表現する」を目的としてstdlibの Result を利用するのは正しい使い方ではありません。「あくまで try-catch の代替である」と理解すべきなのです。
sealed interface も含む sealed 全般を sealed class と表記しています

一方で、「ビジネスロジックとして成功と失敗が存在することを仕様として表現するための型が欲しい」という要求は存在しています。

KEEPにおいては、stdlibとしてはそういったエラーの詳細について場合分けするようなケースをサポートはしないので、自前のユーティリティクラスを用意するかArrowなどのライブラリを使いましょう。と明記されています。

In cases when you need to distinguish between different kinds of failures and these approaches do not work for you, you are welcome to write your own utility libraries or use libraries like Arrow that provide the corresponding utilities.

ビジネスロジック上のエラーを型で表現するために

上記の言語仕様の説明に従い、チェック例外がないことにより失敗時にどうなるかの情報がシグネチャから判断することができず、 stdlibの Result も目的に沿わないため、ビジネスロジックについてエラーが発生する可能性を戻り値の型を使って表現していくことを考えます。

sealed classで結果のデータ型を作る

もっとも簡単な方法として、sealed class を使うことで「失敗するかもしれない」という仕様を型として表現することができます。
この例では、成功時と失敗パターンごとのデータ型を作るようにしています。

sealed interface CreateOrderResult {
    data class Success(val order: Order) : CreateOrderResult
    data class InsufficientStock(val productId: ProductId, val requested: Int, val available: Int) : CreateOrderResult
    data class CreditLimitExceeded(val limit: Money, val requested: Money) : CreateOrderResult
}

fun createOrder(cart: Cart, payment: PaymentMethod): CreateOrderResult

呼び出し側はコンパイル時に「このメソッドは失敗するかもしれない」ということを認識でき、その処理を強制されます。ドキュメントを読み忘れていても、型システムが教えてくれるわけです。 また、when 式で分岐する際にKotlinコンパイラはすべてのケースが網羅されているかをチェックしてくれるため、新しいエラー種別が追加された場合、対応していない箇所はコンパイルエラーになり、ハンドリング漏れを防げます。

when (val result = createOrder(cart, payment)) {
    is CreateOrderResult.Success -> // 成功時の処理
    is CreateOrderResult.InsufficientStock -> // 在庫不足を表示、数量変更を促す
    is CreateOrderResult.CreditLimitExceeded -> // 与信枠超過を表示、別の支払い方法を促す
    // 新しいケースが追加されたらここでコンパイルエラー
}

小さなアプリケーションであればこのアプローチで十分ですが、実際の業務アプリケーションでは複数の処理を組み合わせる必要があります。処理ごとに個別の結果型を定義し、それぞれを when 式で取り出していくと、ネストが深くなりLOCも増えて可読性が下がります。

// 各処理ごとに個別のResult型を定義した場合
fun processOrder(orderId: String): FinalResult {
    val orderResult = findOrder(orderId)
    return when (orderResult) {
        is FindOrderResult.Success -> {
            val validationResult = validateOrder(orderResult.order)
            when (validationResult) {
                is ValidateOrderResult.Success -> {
                    val paymentResult = processPayment(orderResult.order)
                    when (paymentResult) {
                        is PaymentResult.Success -> FinalResult.Success(paymentResult.receipt)
                        is PaymentResult.Failure -> FinalResult.Failure(paymentResult.error)
                    }
                }
                is ValidateOrderResult.Failure -> FinalResult.Failure(validationResult.error)
            }
        }
        is FindOrderResult.NotFound -> FinalResult.Failure("Order not found")
    }
}

このようなネストの深いコードは、処理が増えるほど複雑になり、本来のビジネスロジックが埋もれてしまいます。
保守性を高めるために型安全にしたのはいいですが、認知負荷を上げることになるのは本末転倒です。

汎用的なResult<T, E>の必要性

この問題を解決するのが、成功と失敗を抽象化した汎用的な結果型です。成功時の値の型 T とエラーの型 E を型パラメータとして持つ Result<T, E> のような型を用意し map()flatMap() といった操作を実装することで、複数の処理を宣言的に連鎖させることができます。

  • map():成功時の値を変換する。失敗時は何もせずそのまま返す
  • flatMap():成功時に次の処理(これも結果型を返す)を実行し、失敗時はそのまま失敗を伝播させる

これらを使うと、「成功したら次へ、失敗したらそこで終了」というパターンをネストなく表現できます。

// 汎用的なResult型を使った場合
fun processOrder(orderId: String): Result<Receipt, OrderError> {
    return findOrder(orderId)
        .flatMap { order -> validateOrder(order) }
        .flatMap { validatedOrder -> processPayment(validatedOrder) }
}

このように、汎用的な結果型を導入することで、エラーハンドリングのボイラープレートを排除し、処理の流れを直線的に表現できるようになります。各処理が「成功したら次へ、失敗したらそこで終了」という共通のパターンに従うことを、型レベルで保証できる点も大きな利点です。
ここで、汎用的な結果型を利用するための選択肢としては、これらが候補としてあがります。

  1. [OSS] kotlin-resultの Result<T, E>
  2. [OSS] Arrow-ktの Either<A, B>
  3. 自作 Result

この中で、コドモンではArrow-ktの Either 型を採用しており、その理由について説明していきます。

Arrow-ktのEither型を利用している理由

コドモンでは汎用的な結果型としてArrow-ktの Either 型を採用しています。その理由は、Either 型そのものの設計と、周辺のDSLが充実しており、エラーハンドリングを含むコードを逐次処理のように自然に書けるためです。

Arrow-ktとは

Arrow-ktはKotlin向けの関数型プログラミングライブラリです。Scalaのエコシステムから影響を受けており、関数型プログラミングのパターンやデータ型をKotlinで利用できるようにしています。

本記事で紹介する Either 型のほかにも、OptionIorNonEmptyList といったデータ型や、Opticsと呼ばれるイミュータブルなデータ構造を操作するための仕組みなど、幅広い機能を提供しています。

Either型の特徴

Arrow-ktの Either<A, B> は、Left(左)または Right(右)のいずれかの値を持つ型です。慣例として Left にエラー、Right に成功値を格納します。

Either はRight-biasedな設計になっています。つまり、map()flatMap() といった関数は Right 側の値に対して作用し、Left の場合は何もせずそのまま返すということです。LeftRight どっちが正常系か忘れてしまう場合は 「Right = 正しい」のほうが正常系と、ダブルミーニングで覚えればいいので簡単ですね。
なお、この Either 型の参考元となったScalaの Either 型は昔はどちらかにbiasedではなく純粋に2値どちらかを取るというものだったのですが、Right-biasedに変わっています。それにより Result 的な扱いがしやすくなっている側面がありますが、型が持つ意味としては2値いずれかであるということが他の目的での利用に活きてきます。

val result: Either<CreateOrderError, Order> = createOrder(cart, payment)

// Right の場合のみ変換される、Left の場合はそのまま
val orderId: Either<CreateOrderError, OrderId> = result.map { it.id }

Left 側を変換したい場合は mapLeft() を使います。これにより、異なるエラー型を持つ Either を合成する際に型を揃えられます。

sealed interface CreateOrderError {
    data class Stock(val error: StockError) : CreateOrderError
    data class Payment(val error: PaymentError) : CreateOrderError
}

fun createOrder(cart: Cart, payment: PaymentMethod): Either<CreateOrderError, Order> {
    return checkStock(cart)
        // エラー型をCreateOrderErrorに揃える
        .mapLeft { CreateOrderError.Stock(it) }
        .flatMap { stock ->
            validatePayment(payment)
                // 同様にエラー型をCreateOrderErrorに揃える => flatMapで合成できる
                .mapLeft { CreateOrderError.Payment(it) }
                .map { credit -> Order.create(cart, stock, credit) }
        }
}

Arrow-ktのEither関連DSL

前述の flatMap() チェーンは、when 式のネストに比べれば格段に読みやすくなります。しかし、合成する Either の数が増えてくると、flatMap()map() の組み合わせが複雑になることがあります。

fun processOrder(orderId: String): Either<OrderError, Receipt> {
    return findOrder(orderId)
        .flatMap { order ->
            validateOrder(order).flatMap { validatedOrder ->
                checkInventory(validatedOrder).flatMap { inventory ->
                    processPayment(validatedOrder, inventory).map { payment ->
                        createReceipt(validatedOrder, payment)
                    }
                }
            }
        }
}

処理が増えるほどネストが深くなり、結局「本来のビジネスロジックが埋もれる」という問題が再び現れます。

この問題に対するアプローチとして、Arrow-ktには either {} ブロックというDSLが用意されており、このブロック内では Either を返す処理を bind() で展開することで、あたかも成功前提の逐次処理のように記述できるようになっています。

fun createOrder(cart: Cart, payment: PaymentMethod): Either<CreateOrderError, Order> = either {
    val stock = checkStock(cart).bind()           // Either<CreateOrderError, Stock> から Stock を取り出す
    val credit = validateCredit(payment).bind()   // 失敗したらここで処理が中断され Left が返る
    val order = Order.create(cart, stock, credit)
    order
}

bind()EitherRight であれば中身を取り出し、Left であればその時点で either {} ブロック全体が Left を返して終了します。これにより、flatMap() のネストを避けつつ、成功パスを直線的に記述できます。

// kotlin-result の場合
fun createOrder(cart: Cart, payment: PaymentMethod): Result<Order, CreateOrderError> = binding {
    val stock = checkStock(cart).bind()
    val credit = validateCredit(payment).bind()
    Order.create(cart, stock, credit)
}

Arrow-ktが他の選択肢と比べて優れているところは、この either {} ブロック内で利用できる関数として bind() 以外にも便利なDSLが揃っている点です。可能な限り逐次処理のように簡単に書けるようにしている一方で、想定される汎用 Result 型としてのユースケースに対して広くサポートされていることで、コードの保守性が挙げられると考えています。

なお、kotlin-resultにも binding {} という同様の仕組みがあり、この点では同等の記述が可能です。しかしながら binding {} のブロック内で簡単にエラーに寄せるための関数が足りていないために binding {} を利用するメリットが薄くなっています。
また、自作で Result<T, E> を実装したうえでこれらの仕組みを整えるのはやや保守コストがかかります。

以下に either {} ブロック内でよく使う代表的なものを記載していきます。

raise

raise() を使うと、ブロック内の任意の箇所で Left を返して処理を中断できます。条件分岐の中でエラーを返したい場合に便利です。

fun createOrder(cart: Cart, payment: PaymentMethod): Either<CreateOrderError, Order> = either {
    if (cart.items.isEmpty()) {
        raise(CreateOrderError.EmptyCart)
    }
    val stock = checkStock(cart).bind()
    // ...
}

ensure

ensure() は条件を満たさない場合にエラーを発生させる関数です。バリデーションを簡潔に記述できます。

fun createOrder(cart: Cart, payment: PaymentMethod): Either<CreateOrderError, Order> = either {
    ensure(cart.items.isNotEmpty()) { CreateOrderError.EmptyCart }
    ensure(payment.isValid()) { CreateOrderError.InvalidPayment }
    val stock = checkStock(cart).bind()
    // ...
}

zipOrAccumulate

zipOrAccumulate() は複数の検証を並列に実行し、すべてのエラーを蓄積します。bind()ensure() は最初のエラーで処理が中断されますが、フォームのバリデーションのように「すべてのエラーを一度に返したい」場合に便利です。また、「Left の場合は少なくとも1つはエラーが存在する」という仕様を満たすために Left の場合 NonEmptyList<A> 型となっているのも特徴です。

fun validateOrder(cart: Cart, payment: PaymentMethod): Either<NonEmptyList<CreateOrderError>, ValidatedOrder> = either {
    zipOrAccumulate(
        { ensure(cart.items.isNotEmpty()) { CreateOrderError.EmptyCart } },
        { ensure(payment.isValid()) { CreateOrderError.InvalidPayment } },
        { ensure(cart.totalAmount > Money.ZERO) { CreateOrderError.ZeroAmount } }
    ) { _, _, _ ->
        ValidatedOrder(cart, payment)
    }
}

さらに、either {} ブロック内では zipOrAccumulate()(エラー蓄積)と bind()(fail-fast)を自然に組み合わせることができます。バリデーションですべてのエラーを収集した後、後続の処理はfail-fastで進めたい、というケースに対応できます。

fun createOrder(cart: Cart, payment: PaymentMethod): Either<NonEmptyList<CreateOrderError>, Order> = either {
    // バリデーション:エラーを蓄積
    zipOrAccumulate(
        { ensure(cart.items.isNotEmpty()) { CreateOrderError.EmptyCart } },
        { ensure(payment.isValid()) { CreateOrderError.InvalidPayment } }
    ) { _, _ -> }
    
    // 後続処理:fail-fast
    val stock = checkStock(cart).bindNel()
    val credit = validateCredit(payment).bindNel()
    Order.create(cart, stock, credit)
}

kotlin-resultにも zipOrAccumulate() は存在しますが、binding {} ブロック内でのDSLとしては提供されていないため、同様の処理を書く場合は zipOrAccumulate()binding {} を分離し、エラー型の変換を手動で行う必要があります。

トレードオフ

これらのDSLは強力ですが、トレードオフも存在します。

ここで挙げたDSLを用いたプログラミングはArrow-kt固有のものであり、チームメンバー全員がこれらの書き方を理解している必要があります。具体的なメカニズムを知らなくても、ある程度おまじないとして理解して使うこともできるものの、一定の学習コストは必ず発生します。また、Kotlinの標準的な書き方から離れることへの懸念も少々あります。

また、kotlin-resultの Resultinline classvalue class)として実装されているため、成功時にはオブジェクトのアロケーションが発生しないというパフォーマンス上の利点があります。Arrow-ktの Eithersealed class であるため、この点ではkotlin-resultに劣ります。 zenn.dev

それでも、これらを理解してしまえば複雑なエラーハンドリングを含むビジネスロジックを可読性高く記述できることになるメリットは大きく、コドモンではこのトレードオフを受け入れてArrow-ktを採用しています。

Either型と例外の使い分け

ここまでは、Arrow-ktの Either 型を利用するメリットについて説明しましたが、一方で例外を一切利用しないわけではないです。

これはstdlibの Result<T> の説明で引用したKEEPにある以下の記述に関連します。

However, when interfacing with Java-style APIs that rely heavily on exceptions or otherwise having a need to somehow process exceptions locally (as opposed to propagating them up the call stack), we see a clear lack of primitives in the Kotlin standard library.

例外をスローすべき状況

この代表的な例がデータベースアクセスにJDBCを利用している場合です。

JDBCドライバを利用しているとして、これらが発するエラーはほぼビジネスロジックとは関係がなく、ハンドリングの必要性が極めて低いものです。 それらをローカルで(※)で捕捉したとしても、捕捉した ResultEither をコールツリーの上流、WebアプリケーションならHTTPのレイヤでバッチ処理なら main 関数等まで伝播することになります。結局はWebアプリケーションならフレームワークがキャッチして5xx系のレスポンスを返却するだけですし、バッチ処理なら異常終了するだけとなります。
※ ここでいうローカルとはエラー発生箇所の直ぐ近くということを指します

このような状況では、無理に EitherResult で包むよりも例外をそのままスローしてしまう方がメリットがあります。
たとえばJDBCのエラーを Either で包む場合と、そのままスローする場合はそれぞれこのようになります。

// Either で包む場合
fun findUser(id: UserId): Either<DatabaseError, User> {
    return try {
        val row = jdbcTemplate.queryForMap("SELECT * FROM users WHERE id = ?", id.value)
        User(
            id = UserId(row["id"] as Long),
            name = row["name"] as String
        ).right()
    } catch (e: DataAccessException) {
        DatabaseError(e).left()
    }
}

// 例外をそのまま throw する場合
fun findUser(id: UserId): User {
    val row = jdbcTemplate.queryForMap("SELECT * FROM users WHERE id = ?", id.value)
    return User(
        id = UserId(row["id"] as Long),
        name = row["name"] as String
    )
}

自分たちのアプリケーションの中に DatabaseErrorConnectionErrorTransactionError のような、結局キャッチしても何もできないエラー型を定義・維持する必要がなくなります。これらの型は存在しても分岐処理に使われることがほぼなく、ただの「例外のラッパー」になりがちです。 これは不用意に複雑にするだけで保守性を下げる要因となります。
また、結果としてメソッドシグネチャがシンプルになるので、不用意に flatMap()bind() 等で合成する必要もなく、テストも減らせることにもなるはずです。

どう使い分けるか

コドモンでは、 ビジネスロジックとして仕様上考慮が必要なものは Either で表現し、IO系のエラーは例外としてそのまま上流に伝播させる という使い分けに落ち着いています。これにより、どのエラーがハンドリング対象なのかが型から明確になるのです。

Either に包まれているならビジネス上の判断が必要、例外ならインフラ障害として上位層に任せる——この区別がコードベース全体で一貫していれば、可読性も保守性も向上します。

同僚の上代がアプリケーションアーキテクチャふまえたエラーハンドリングの設計についてお話させていただいており、そちらも併せて参照することで使い分けの指針にもなるはずです。

まとめ

本記事では、コドモンにおけるエラーハンドリングの考え方について紹介しました。

  • ビジネスロジック上のエラーは型で表現する:例外ではなく sealed classEither 型を使うことで、エラーの可能性がシグネチャに現れ、コンパイラによる網羅性チェックが効くようになります
  • stdlibの Result 型は用途が異なる:KEEPにある通り、stdlibの Resulttry-catch の代替であり、ビジネスロジックのエラー表現には適していません
  • Arrow-ktの Either 型を採用している理由:either {} ブロックや bind()raise()ensure() といったDSLが充実しており、エラーハンドリングを含むコードを逐次処理のように自然に書けます
  • 例外との使い分け:JDBCなどのIO系エラーは例外としてそのままスローし、ビジネスロジックのエラーのみを Either で表現することで、どのエラーがハンドリング対象なのかを型から明確にしています

このように、コドモンでは「OOPをベースにしつつ、関数型の型安全性を取り入れる」というスタイルでKotlinを書いています。エラーハンドリングに限らず、チームで一貫した方針を持つことが可読性と保守性の向上につながります。ぜひ参考にしてみてください。

参考

エラー処理の選択肢を増やす ~try-catchから始めて段階的に型安全へ~ | ドクセル

Kotlinの新しいエラーハンドリング「Rich Errors」 - Don't Repeat Yourself

(翻訳) Kotlinでの型付きエラー処理 - /var/log/jsoizo

複数のバリデーション結果を蓄積したいときのEither<Nel<E>, A>とzipOrAccumurate - /var/log/jsoizo

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