エラーチャネルは2つあるといいぞ

August 04, 2023

エラーチャネルは2つあるといいぞという話です。
加えてエラーチャネルが元々2つあるデータ型はいいぞという話もあります。

エラーチャネルが2つ?

Javaにも例外がCheckedExceptionRuntimeExceptionの2種類ある通り、エラーが2種類ある方が便利だと考えています。

それぞれが何かは適当にググれば色々出てきますが、極端に端折って説明するならばCheckedException俺の責任RuntimeExceptionお前の責任、みたいなイメージです。

具体的な差は以下のような話になります。

  • エラーチャネルが1つしかないとこの2種類のエラーが混ざってしまって不便
  • エラーが分別可能な状態・分別されている状態であった方が便利

エラーチャネルが1つしかない場合

JavascriptのPromise<A>Observable<A>はエラーチャネルが1つしかないので、その事の不便さを伝えやすいかもしれません。

function registerUser(user: User): Promise<User> {
}

registerUser(user)
    .then(user => {
        // ユーザー登録成功
    })
    .catch(error => {
        if (error instanceof UserAlreadyExistsError) {
            // ユーザー登録失敗
        } else if (error instanceof InvalidInputError) {
            // 入力エラー
        } else {
            // 予期せぬエラー
        }
    })

メリット

  • 簡単

デメリット

  • エラーの捕捉がデフォルトで任意であり、プログラマの努力義務である
  • どんなエラーが発生するかシグネチャから知る術がなく、保証する方法もない

エラーチャネルが2つある場合

前述の通り恒久的な不便さがコスパ悪いと考えているため、私はJavascriptを書く時でもEither<A, B>を使ってエラーチャネルを2つにしています(Scala.jsを使えという声が聞こえる)。

type UserRegistrationError = UserAlreadyExistsError | InvalidInputError

function registerUser(user: User): Promise<Either<UserRegistrationError, User>> {
}

registerUser(user)
    .then(result => match(result)(
        error => {
            if (error instanceof UserAlreadyExistsError) {
                // ユーザー登録失敗
            } else if (error instanceof InvalidInputError) {
                // 入力エラー
            }
        },
        user => {
            // ユーザー登録成功
        }
    ))
    .catch(error => {
        // 予期せぬエラーのみ
    })

メリット

  • エラーを捕捉しなければ期待する値Userの取得ができず、エラーを取りこぼしづらい
  • どんなエラーが発生するのかシグネチャから分かる

デメリット

  • データ型がネストしていて面倒臭い

モナドトランスフォーマーによってネストしたデータ型の面倒さの解消はできる

前述のようにエラーチャネルが1つしかないデータ型F[_]にエラーチャネルを追加するにはEither[A, B]を中に入れる方法がありますが、それに付随する手前コストが若干高い課題があります。(特に処理を連続する場合顕著です)
これはモナドトランスフォーマーを使う事である程度は解消できはするのです。

def registerUser(user: User): F[Either[UserRegistrationError, User]] = ???

val result: EitherT[F, UserRegistrationError, User] = for {
  user <- EitherT.fromEither[F](registerUser(user))
} yield {
  // ユーザー登録成功
  user
}

result.handleError {
  case e: UserAlreadyExsitsError => // ユーザー登録失敗
  case e: InvalidInputError => // 入力エラー
}

毎回F[Either[A, B]]からEither[A, B]を取り出してパターンマッチせざるを得なかった状態からは素晴らしい躍進です・・!
嫌いではないのですが、全てEitherT[F, A, B]に変換しなければならず、またこれがCPUコスト的にもあまり優しくないらしいので、可能なら無くしたいよねという気持ちはわかります。

ZIOは標準でエラーチャネルが2つある

ZIOには主要なエラーチャネルCause[+E]が1つあり、それがCheckedException相当のFail[+E]RuntimeException相当のDieを持ち合わせています。

sealed abstract class Cause[+E] extends Product with Serializable { self =>
  import Cause._
  def trace: Trace = ???

  final def ++[E1 >: E](that: Cause[E1]): Cause[E1] = Then(self, that)
  final def &&[E1 >: E](that: Cause[E1]): Cause[E1] = Both(self, that)
}

object Cause extends Serializable {
  case object Empty extends Cause[Nothing]
  final case class Fail[+E](value: E, override val trace: Trace) extends Cause[E]
  final case class Die(value: Throwable, override val trace: Trace) extends Cause[Nothing]
  final case class Interrupt(fiberId: FiberId, override val trace: Trace) extends Cause[Nothing]
  final case class Stackless[+E](cause: Cause[E], stackless: Boolean) extends Cause[E]
  final case class Then[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
  final case class Both[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
}

https://zio.dev/reference/core/cause/

なので常にZIO[R, E, A]のままでよく、中にEither[A, B]を入れてやる必要もありません。 もちろんEitherT[F, A, B]に入れ直す必要もありませんし、素晴らしいですね!

正直これだけでもZIOを使う理由になっても良いと思っていますが、強力なRもあったりと魅力は尽きません。

まとめ

  • エラーチャネルが1つしかないと人間が頑張らないといけなくて大変
  • エラーチャネルが2つあるとコンパイラーに仕事させて楽ができる
    • テストも書かずに正しさを証明できるので最高

Profile picture

Shota Hoshino
A functional scala developer