9. MonadError - polymorphic programs
def program[F[_], E](value: F[Int], e: => E)
(implicit M: MonadError[F, E]): F[Int] = for {
n <- value
result <- if (n < 0) M.raiseError(e)
else M.pure(n * 2)
} yield result
10. MonadError - instances
val resEither = program(43.asRight[String], "Error")
val resIO = program(IO.pure(-5), new Throwable())
val resOption = program(Option(21), ())
val resEitherT =
program(EitherT.pure[List, String](9), "Fail!")
val resOptionStateT =
program(StateT.get[Option, Int], ())
11. MonadError - instances
Three main types of instances:
1. Simple data types like Either, Option or Ior
2. IO-like types, the various cats.effect.IO, monix.eval.Task, but also
scala.concurrent.Future
3. Monad transformers, which get their instances from their
respective underlying monads
12. MonadError - instances
Three main types of instances:
1. Simple data types like Either, Option or Ior
2. IO-like types, the various cats.effect.IO, monix.eval.Task, but also
scala.concurrent.Future
3. (Monad transformers, which get their instances from their
respective underlying monads)
13. MonadError - a closer look
def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
If the errors are handled, why does it return the exact same type?
What happens if I return errors in the E => F[A] function?
14. MonadError - a closer look
def attempt[A](fa: F[A]): F[Either[E, A]]
There is no way the outer F still has any errors, so why does it have the
same type?
Shouldn’t we represent the fact that we handled all the errors in the
type system?
15. Let’s try better!
We could use two type constructors to represent fallible and
non-fallible types!
trait ErrorControl[F[_], G[_], E] extends MonadRaise[F, E] {
def controlError[A](fa: F[A])(f: E => G[A]): G[A]
}
16. Let’s try better!
We could use two type constructors to represent fallible and
non-fallible types!
trait ErrorControl[F[_], G[_], E] extends MonadRaise[F, E] {
def monadG: Monad[G]
def controlError[A](fa: F[A])(f: E => G[A]): G[A]
}
19. ErrorControl - derived functions
It’d also be nice to lift infallible values in G back into F
def accept[A](ga: G[A]): F[A]
or
def accept: G ~> F
Then we can define things like this:
def absolve[E, A](gea: G[Either[E, A]]): F[A]
def assure[A](ga: G[A])(error: A => Option[E]): F[A]
21. ErrorControl - more laws
controlError(accept(ga))(f) === ga
accept(intercept(fa)(f)) === handleError(fa)(f)
accept(trial(fa)) === attempt(fa)
22. ErrorControl - instances - Either
implicit def errorControlEither[E] =
new ErrorControl[Either[E, ?], Id, E] {
val monadG = Monad[Id]
def controlError[A](fa: Either[E, A])(f: E => A): A =
fa match {
case Left(e) => f(e)
case Right(a) => a
}
def accept[A](ga: A): Either[E, A] = Right(ga)
}
23. ErrorControl - instances - IO
What would be the infallible version of IO?
Unexceptional IO, short UIO
UIO is a type with absolutely no errors at all
type IO[A] = UIO[Either[Throwable, A]]
Lives in cats-uio …
24. ErrorControl - instances - IO
What would be the infallible version of IO?
Unexceptional IO, short UIO
UIO is a type with absolutely no errors at all
type IO[A] = UIO[Either[Throwable, A]]
Lives in cats-uio …for now
26. ErrorControl - instances - IO
If IO is just UIO[Either[Throwable, A]], then of the three
groups of instances at the beginning only one really
remains.
It all boils down to Either!
27. ErrorControl - instances - EitherT
implicit def errorControlEitherT[G[_]: Monad, E] =
new ErrorControl[EitherT[G, E, ?], G, E] {
val monadG = Monad[G]
def controlError[A](fa: EitherT[G, E, A])
(f: E => G[A]): G[A] = fa.value.flatMap {
case Left(e) => f(e)
case Right(a) => a.pure[G]
}
def accept[A](ga: G[A]): EitherT[G, E, A] =
EitherT.liftF(ga)
}
28. ErrorControl - instances - StateT
implicit val errorControlStateT[F[_], G[_], E]
(implicit E: ErrorControl[F, G, E]) =
new ErrorControl[StateT[F, E, ?], StateT[G, E, ?], E] {
val monadG = Monad[StateT[G, E, ?]]
def controlError[A](fa: StateT[F, E, A])
(f: E => StateT[G, E, A]): StateT[G, E, A] =
StateT { s =>
E.controlError(fa.run(s))(e => f(e).run(s)) }
def accept[A](ga: StateT[G, E, A]): StateT[F, E, A] =
ga.mapK(FunctionK.lift(E.accept))
}
30. ErrorControl - instances - BIO
implicit val errorControlBIO[E] =
new ErrorControl[BIO[E, ?], BIO[Nothing, ?], E] {
val monadG = Monad[BIO[E, ?]]
def controlError[A](fa: BIO[E, A])
(f: E => BIO[Nothing, A]): BIO[Nothing, A] =
fa.controlError(f)
def accept[A](ga: BIO[Nothing, A]): BIO[E, A] =
ga.leftWiden
}
31. Is that it?
Caveats
● Working with two type
constructors can be difficult
● Not all errors can be recovered
from
● There might be at least 3
different types of errors.
● We can divide errors into
recoverable/non-recoverable
or retryable/non-retryable or
domain errors
32. trait ErrorControl[F[_, _]] extends Bifunctor[F] {
def monadThrow[E]: MonadThrow[F[E, ?]]
def controlError[E, A](fea: F[E, A])
(f: E => F[Nothing, A]): F[Nothing, A]
}
This is cool, but requires an adapter for types like IO or Task.
If BIO becomes the new standard, maybe we should revisit this
Alternatives?
35. Conclusions
UIO is the primitive type for side-effectful computation
Most asynchronous computations can throw errors though, so it’s not as
wide-spread
Either is the primitive for error handling
It gives us short circuiting on the first error, similar to synchronous
exceptions
Composing them gives us something like ErrorControl
Or something like BIO
37. Bonus Slides
How to run a bunch of IOs in parallel and accumulate
errors along the way?
type VIO[E, A] = UIO[Validated[E, A]]
def fetch(s: String): VIO[NonEmptySet[Error], User]
list.traverse(fetch)
38. Bonus Slides
How to support non-fatal errors where no
short-circuiting occurs?
type IOr[E, A] = UIO[Ior[E, A]]
Either does not have to be the primitive for error
handling