Error management
Producing errors
Problem
One of the major issues of the MTL style is an error handling.
The weakest Cats
typeclass, which enables operations with errors, is an
ApplicativeError
.
It brings a full Applicative
instance apart from error-related methods and
this means, that we are not allowed to have a few FunctorRaise
or ApplicativeError
instances in the scope, since
their underlying Functor
/Applicative
instances will come into conflict:
import cats._
case class ArithmeticError() extends Throwable
case class ParseError() extends Throwable
def divideBad[F[_]](x: String, y: String)(implicit
F1: ApplicativeError[F, ArithmeticError],
F2: ApplicativeError[F, ParseError]): F[String] =
// using Functor / Applicative syntax here will cause an
// "ambiguous implicit values" error
???
So we are forced to choose a single unified error type.
Solution
The simplest solution here is to create a typeclass, that is not a subtype of Functor:
trait Raise[F[_], E]{
def raise[A](err: E): F[A]
}
(see also
cats-mtl 's FunctorRaise
).
It would allow us to distinguish between different types of errors:
import cats.effect.IO
import tofu._
import tofu.syntax.monadic._
import tofu.syntax.raise._
def divide[F[_]: Monad](x: String, y: String)(implicit
F1: Raise[F, ArithmeticError],
F2: Raise[F, ParseError]
): F[String] =
( x.toIntOption.orRaise(ParseError()),
y.toIntOption.orRaise(ParseError())
.verified(_ != 0)(ArithmeticError())
).mapN(_ / _).map(_.toString)
divide[IO]("10", "3").attempt.unsafeRunSync()
divide[IO]("10","0").attempt.unsafeRunSync()
divide[IO]("1", "0").attempt.unsafeRunSync()
Recovering from errors
Problem
ApplicativeError
provides the following method for error handling:
def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
Here, if f
does not fail, F[A]
should describe a successful computation. The types, however, do not convey this fact,
since we have no type for Unexeptional
partner. Read more here
Solution
Tofu
is shipped with a few typeclasses targeting the problem. The simplest one is
trait RestoreTo[F[_], G[_]] {
def restore[A](fa: F[A]): G[Option[A]]
}
which can be used to restore from any failure condition.
Another one is
trait HandleTo[F[_], G[_], E] extends RestoreTo[F, G] {
def handleWith[A](fa: F[A])(f: E => G[A]): G[A]
def handle[A](fa: F[A])(f: E => A)(implicit G: Applicative[G]): G[A] =
handleWith(fa)(e => G.pure(f(e)))
def attempt[A](fa: F[A])(implicit F: Functor[F], G: Applicative[G]): G[Either[E, A]] =
handle(F.map(fa)(_.asRight[E]))(_.asLeft)
}
which can handle concrete error type:
import cats._
import cats.data.EitherT
import cats.instances.vector._
import cats.syntax.foldable._
import cats.syntax.traverse._
import tofu._
import tofu.syntax.handle._
import tofu.syntax.monadic._
import tofu.syntax.raise._
def splitErrors[
T[_]: Traverse: Alternative,
F[_]: Functor, G[_]: Applicative, E, A](ls: T[F[A]])(
implicit errors: ErrorsTo[F, G, E]
): G[(T[E], T[A])] =
ls.traverse(_.attemptTo[G, E]).map(_.partitionEither(identity))
def parseInt[F[_]: Applicative: Raise[*[_], String]](s: String): F[Int] =
s.toIntOption.orRaise(s"could not parse $s")
type Calc[A] = EitherT[Eval, String, A]
splitErrors[Vector, Calc, Eval, String, Int](
Vector("1", "hello", "2", "world", "3").map(parseInt[Calc])
).value
HandleTo, empowered with Raise, is called ErrorsTo
:
trait ErrorsTo[F[_], G[_], E] extends Raise[F, E] with HandleTo[F, G, E]
There are also specialized versions of RestoreTo
, HandleTo
and ErrorsTo
without To
:
trait Restore[F[_]] extends RestoreTo[F, F] {
def restoreWith[A](fa: F[A])(ra: => F[A]): F[A]
}
trait Handle[F[_], E] extends HandleTo[F, F, E] with Restore[F] {
def tryHandleWith[A](fa: F[A])(f: E => Option[F[A]]): F[A]
def tryHandle[A](fa: F[A])(f: E => Option[A])(implicit F: Applicative[F]): F[A] =
tryHandleWith(fa)(e => f(e).map(F.pure))
def handleWith[A](fa: F[A])(f: E => F[A]): F[A] =
tryHandleWith(fa)(e => Some(f(e)))
def recoverWith[A](fa: F[A])(pf: PartialFunction[E, F[A]]): F[A] =
tryHandleWith(fa)(pf.lift)
def recover[A](fa: F[A])(pf: PartialFunction[E, A])(implicit F: Applicative[F]): F[A] =
tryHandle(fa)(pf.lift)
def restoreWith[A](fa: F[A])(ra: => F[A]): F[A] = handleWith(fa)(_ => ra)
}
trait Errors[F[_], E] extends Raise[F, E] with Handle[F, E] with ErrorsTo[F, F, E]