ApplicativeError and MonadError
API Documentation: ApplicativeError, MonadError
Applicative Error
Description
ApplicativeError
extends Applicative
to provide handling for types
that represent the quality of an exception or an error, for example, Either[E, A]
TypeClass Definition
ApplicativeError
is defined by the following trait
trait ApplicativeError[F[_], E] extends Applicative[F] {
def raiseError[A](e: E): F[A]
def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
def handleError[A](fa: F[A])(f: E => A): F[A]
def attempt[A](fa: F[A]): F[Either[E, A]]
//More functions elided
}
Use Case
Either
We can start with a less abstract way of performing a function. Here we will divide one number by another.
def attemptDivide(x: Int, y: Int): Either[String, Int] = {
if (y == 0) Left("divisor is zero")
else {
Right(x / y)
}
}
While fine in the above approach, we can abstract the Either
away
to support any other kind of "error" type without having to
create multiple functions with different "container" types.
import cats._
import cats.syntax.all._
def attemptDivideApplicativeError[F[_]](x: Int, y: Int)(implicit ae: ApplicativeError[F, String]): F[Int] = {
if (y == 0) ae.raiseError("divisor is error")
else {
ae.pure(x/y)
}
}
The above method summons ApplicativeError
to provide behavior representing
an error where the end-user, based on type, will get their
appropriate response.
ApplicativeError
is an Applicative
, which means all Applicative
functions are available for use. One such method is pure
, which will
return the F[_]
representation, where F
could represent Either
.
Another method that you will see is raiseError
, which will generate
the specific error type depending on what F[_]
represents.
If F[_]
is an Either
, then ae.raiseError
will return Left
.
If F[_]
represents a Validation
, then ae.raiseError
will return Invalid
.
For example, if we want to use an Either
as our error representation,
we can do the following:
type OnError[A] = Either[String, A]
val e: OnError[Int] = attemptDivideApplicativeError(30, 10)
or simply via assignment
val f: Either[String, Int] = attemptDivideApplicativeError(30, 10)
Validated
Given the same function attemptDivideApplicativeError
, we can call that
function again but with a different return type, since the ApplicativeError
can support other "error" based types. Here we will use cats.data.Validated
when calling attemptDivideApplicativeError
. Notice that
attemptDivideApplicativeError
is the same as we defined above,
so we make no other changes.
import cats.syntax.all._
import cats.data.Validated
type MyValidated[A] = Validated[String, A]
val g = attemptDivideApplicativeError[MyValidated](30, 10)
We can inline the right projection type alias, MyValidated
, doing the following:
val h = attemptDivideApplicativeError[({ type T[A] = Validated[String, A]})#T](30, 10)
Or we can use KindProjector to make this more refined and readable
val j = attemptDivideApplicativeError[Validated[String, *]](30, 10)
It is an Applicative
after all
As a Reminder, this is an Applicative
so all the methods of Applicative
are available to you to use in
manipulating your values, ap
, mapN
, etc. In the following example, notice
we are using Applicative
's map2
, and of course, pure
which also is a
form of Applicative
.
import cats.syntax.all._
def attemptDivideApplicativeErrorWithMap2[F[_]](x: Int, y: Int)(implicit ae: ApplicativeError[F, String]): F[_] = {
if (y == 0) ae.raiseError("divisor is error")
else {
val fa = ae.pure(x)
val fb = ae.pure(y)
ae.map2(fa, fb)(_ / _)
}
}
Handling Errors
ApplicativeError
has methods to handle what to do when F[_]
represents an error.
In the following example, attemptDivideApplicativeErrorAbove2
creates an error representation if the divisor
is 0
or 1
with the message "Bad Math" or "Waste of Time".
We will feed the result from attemptDivideApplicativeErrorAbove2
into the handler
method, where this method will pattern match on the message
and provide an alternative outcome.
import cats.syntax.all._
def attemptDivideApplicativeErrorAbove2[F[_]](x: Int, y: Int)(implicit ae: ApplicativeError[F, String]): F[Int] =
if (y == 0) ae.raiseError("Bad Math")
else if (y == 1) ae.raiseError("Waste of Time")
else ae.pure(x / y)
def handler[F[_]](f: F[Int])(implicit ae: ApplicativeError[F, String]): F[Int] = {
ae.handleError(f) {
case "Bad Math" => -1
case "Waste of Time" => -2
case _ => -3
}
}
Running the following will result in Right(-1)
handler(attemptDivideApplicativeErrorAbove2(3, 0))
handleErrorWith
is nearly the same as handleError
but
instead of returning a value A
, we will return F[_]
. This could provide us
the opportunity to make it very abstract and return a value from a Monoid.empty
.
def handlerErrorWith[F[_], M[_], A](f: F[A])(implicit F: ApplicativeError[F, String], M:Monoid[A]): F[A] = {
F.handleErrorWith(f)(_ => F.pure(M.empty))
}
Running the following will result in Right(0)
handlerErrorWith(attemptDivideApplicativeErrorAbove2(3, 0))
Handling Exceptions
There will inevitably come a time when your nice ApplicativeError
code will
have to interact with exception-throwing code. Handling such situations is easy
enough.
def parseInt[F[_]](input: String)(implicit F: ApplicativeError[F, Throwable]): F[Int] =
try {
F.pure(input.toInt)
} catch {
case nfe: NumberFormatException => F.raiseError(nfe)
}
parseInt[Either[Throwable, *]]("123")
// res2: Either[Throwable, Int] = Right(value = 123)
parseInt[Either[Throwable, *]]("abc")
// res3: Either[Throwable, Int] = Left(
// value = java.lang.NumberFormatException: For input string: "abc"
// )
However, this can get tedious quickly. ApplicativeError
has a catchOnly
method that allows you to pass it a function, along with the type of exception
you want to catch, and does the above for you.
def parseInt[F[_]](input: String)(implicit F: ApplicativeError[F, Throwable]): F[Int] =
F.catchOnly[NumberFormatException](input.toInt)
parseInt[Either[Throwable, *]]("abc")
// res4: Either[Throwable, Int] = Left(
// value = java.lang.NumberFormatException: For input string: "abc"
// )
If you want to catch all (non-fatal) throwables, you can use catchNonFatal
.
def parseInt[F[_]](input: String)(implicit F: ApplicativeError[F, Throwable]): F[Int] = F.catchNonFatal(input.toInt)
parseInt[Either[Throwable, *]]("abc")
// res5: Either[Throwable, Int] = Left(
// value = java.lang.NumberFormatException: For input string: "abc"
// )
MonadError
Description
Since a Monad
extends an Applicative
, there is naturally a MonadError
that
will extend the functionality of the ApplicativeError
to provide flatMap
composition.
TypeClass Definition
The Definition for MonadError
extends Monad
which provides the
methods, flatMap
, whileM_
. MonadError
also provides error
handling methods like ensure
, ensureOr
, adaptError
, rethrow
.
trait MonadError[F[_], E] extends ApplicativeError[F, E] with Monad[F] {
def ensure[A](fa: F[A])(error: => E)(predicate: A => Boolean): F[A]
def ensureOr[A](fa: F[A])(error: A => E)(predicate: A => Boolean): F[A]
def adaptError[A](fa: F[A])(pf: PartialFunction[E, E]): F[A]
def rethrow[A, EE <: E](fa: F[Either[EE, A]]): F[A]
}
Use Case
Given a method that accepts a tuple of coordinates, it finds the closest city. For this example we will hard-code "Minneapolis, MN," but you can imagine for the sake of In this example, you would either consult a database or a web service.
def getCityClosestToCoordinate[F[_]](x: (Int, Int))(implicit ae: ApplicativeError[F, String]): F[String] = {
ae.pure("Minneapolis, MN")
}
Next, let's follow up with another method, getTemperatureByCity
, that given a
city, possibly a city that was just discovered by its coordinates, we get
the temperature for that city. Here, for the sake of demonstration,
we are hardcoding a temperature of 78°F.
def getTemperatureByCity[F[_]](city: String)(implicit ae: ApplicativeError[F, String]): F[Int] = {
ae.pure(78)
}
With the methods that we will compose in place let's create a method that will
compose the above methods using a for comprehension which
interprets to a flatMap
-map
combination.
getTemperatureByCoordinates
's parameterized type
[F[_]:MonadError[*[_], String]
injects F[_]
into MonadError[*[_], String]
;
thus if the "error type" you wish to use is Either[String, *]
, the Either
would be placed in the hole of MonadError
, in this case,
MonadError[Either[String, *], String]
getTemperatureByCoordinates
accepts a Tuple2
of Int
and Int
and
returns F
, which represents our MonadError
, which can be a type like Either
or
Validated
. In the method, since getCityClosestToCoordinate
and
getTemperatureByCity
both return potential error types and they are monadic, we can
compose them with a for comprehension.
def getTemperatureByCoordinates[F[_]: MonadError[*[_], String]](x: (Int, Int)): F[Int] = {
for { c <- getCityClosestToCoordinate[F](x)
t <- getTemperatureByCity[F](c) } yield t
}
We can call getTemperatureByCoordinates
with the following sample, which will return 78
.
type MyEither[A] = Either[String, A]
getTemperatureByCoordinates[MyEither]((44, 93))
With TypeLevel Cats, how you structure your methods is up to you: if you wanted to
create getTemperatureByCoordinates
without a Scala
context bound for MonadError
,
but create an implicit
parameter for your MonadError
you can have access to some
additional methods.
In the following example, we create an implicit
MonadError
parameter
and call it me
. Using the me
reference, we can call any one of its
specialized methods, like raiseError
, to raise an error representation
when things go wrong.
def getTemperatureByCoordinatesAlternate[F[_]](x: (Int, Int))(implicit me: MonadError[F, String]): F[Int] = {
if (x._1 < 0 || x._2 < 0) me.raiseError("Invalid Coordinates")
else for { c <- getCityClosestToCoordinate[F](x)
t <- getTemperatureByCity[F](c) } yield t
}