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.

getTemperatureFromByCoordinates 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]

getTemperatureFromByCoordinates accepts a Tuple2 of Int and Int, and we return F which represents our MonadError which can be a type like Either or Validated. In the method, since either 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
}

Invoking getTemperatureByCoordinates we can call it with the following sample, which will return 78.

NOTE: infix -> creates a Tuple2. 1 -> "Bob" is the same as (1, "Bob")

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 getTemperatureFromByCoordinatesAlternate[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
}