エラーチャネルは2つあるといいぞという話です。
加えてエラーチャネルが元々2つあるデータ型はいいぞという話もあります。
エラーチャネルが2つ?
Javaにも例外がCheckedException
とRuntimeException
の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]
}
なので常にZIO[R, E, A]
のままでよく、中にEither[A, B]
を入れてやる必要もありません。
もちろんEitherT[F, A, B]
に入れ直す必要もありませんし、素晴らしいですね!
正直これだけでもZIOを使う理由になっても良いと思っていますが、強力なR
もあったりと魅力は尽きません。
まとめ
- エラーチャネルが1つしかないと人間が頑張らないといけなくて大変
- エラーチャネルが2つあるとコンパイラーに仕事させて楽ができる
- テストも書かずに正しさを証明できるので最高