1. Introduction
In this tutorial, we’ll delve into the foundational aspects of error raising and handling using higher-kinded types.
Additionally, we’ll learn the essential capabilities and applications of type classes like ApplicativeError and MonadError from the Cats library.
We’ll also see how these concepts are applied to one of the core data structures in the Cats Effect library, the IO monad.
2. Raising and Handling Errors and Higher-Order Types
Let’s say we are trying to create a simplistic calculating service, where unknown errors could occur. Below, F[???] represents this uncertainty:
trait Calculator[F[_]] {
def calculate(f: => Int): F[???]
}
We can make use of the Either[+A, +B] type from the Scala standard library to encompass potential error cases of type E and the successfully returned value of Int. With the understanding that calculate could potentially raise an error, our abstract data type Calculator[F[_]] takes this from:
trait Calculator[F[_], E] {
def calculate(f: => Int): F[Either[E, Int]]
}
This way of dealing with errors is straightforward: the error is one of two possible values. However, this approach has a few shortcomings. First, it leads to a pretty cluttered code. Secondly, we may need to write a lot of additional code to facilitate transformations of the values, as the value is wrapped in both Either and F. In this case one can also employ EitherT monad transformer*.* However, it’s not always necessary to carry errors all along the way. Instead, we can remain within the context of F[_] and delegate the responsibility of raising and handling errors to the implementation:
trait Calculator[F[_]] {
def calculate(f: => Int): F[Int]
}
Such approach is implemented in the Tagless Final design pattern.
3. Tools for Error Handling From Cats and Cats Effect
Cats offers functionality for error handling through type classes, such as ApplicativeError and MonadError. They become accessible when implicit instances of these type classes are available for the effect type F:
import cats.syntax.applicative._
import cats.{Applicative, ApplicativeError}
class CalculatorImpl[F[_]]()(implicit m: ApplicativeError[F, Throwable]) extends Calculator[F]
Cats Effect provides instances of ApplicativeError and MonadError for IO and additionally there’re a lot of methods for error handling available for IO directly (with the names similar to the ones from ApplicativeError and MonadError).
3.1. Adding Project Dependency
Before we begin, let’s include a dependency for the Cats Effect library:
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.5.2"
)
In the majority of the examples, we’ll utilize Cats library functionality, which is available through this import along with Cats Effect functionality.
3.2. On applicativeError and monadError Syntax
We’ll employ the applicativeError and monadError syntax to make error handling resemble a method call. This approach is similar to how we utilize syntax for Functor and FlatMap, enabling postfix calls such as f.map and f.flatMap through the import of implicits:
import cats.syntax.functor._
import cats.syntax.flatMap._
Similarly, to invoke methods such as raiseError, handleError, and ensure for our effects (e.g. f.handleError), we add:
import cats.syntax.applicativeError._
import cats.syntax.monadError._
3.3. Raising Errors and Understanding ApplicativeError[F[_], E]
ApplicativeError[F[_], E]* allows to raise and handle errors for the higher-order type *F[_]. As an applicative functor, this abstraction enables lifting a value into the context of F. When an implicit instance of ApplicativeError is available for our effect F, such as IO, our effect gains the capabilities of ApplicativeError, e.g., to raise an error:
def raiseError[A](e: E): F[A]
The question might arise: *if we operate on a value of type E and the returned value is F[A], how can we retain error information?* To achieve this, we utilize subtyping. In particular, the implementation of raiseError for IO resolves this concern:
def raiseError[A](t: Throwable): IO[A] = Error(t)
The Error(t) resides in the cats.effect.IO and it is essentially a failed effect IO[Nothing]:
private[effect] final case class Error(t: Throwable) extends IO[Nothing] {
def tag = 1
}
Now we can implement the Calculator[F[_]]:
import cats.syntax.applicative._
import cats.{Applicative, ApplicativeError}
import scala.util.{Try, Success, Failure}
class CalculatorImpl[F[_] : Applicative]()(implicit m: ApplicativeError[F, Throwable]) extends Calculator[F] {
override def calculate(f: => Int): F[Int] =
Try(f) match {
case Success(res) => res.pure[F]
case Failure(_) => m.raiseError[Int](new RuntimeException("Calculation failed"))
}
}
Henceforth, we’ll have the capability to raise errors while staying within the context of the effect F.
3.4. Handling Errors With ApplicativeError[F[_], E]
Next, what will we do with the error? We can handle it by returning some other value right away with handleError, so that the new value is encapsulated within the context of F:
def handleError[A](fa: F[A])(f: E => A): F[A]
We can just discard the error and return some default value employing the function Throwable => Int:
c.calculate("5 * 3".toInt).handleError(_ => -1)
However, as it’s not recommended to ignore the error, at the very least, we should log it. Usually, logging involves using some effect, such as Logger from log4cats. Hence, we’ll utilize the counterpart of handleError, namely handleErrorWith:
def handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
And now we can handle the error better:
c.calculate("5 * 3".toInt).handleErrorWith(e => logger.error(s"error calculating the value: ${e.getMessage}").map(_ => -1))
3.5. ApplicativeError Toolkit
There are numerous other error-handling methods accessible within ApplicativeError, which are extensively employed in the Cats ecosystem. Several of these methods produce a new value of F[_], as we saw above, in one way or another.
With attempt we produce F[Either[E, A]] out of F[A] so that F[Left[E, A]] returns in case of error and F[Right[E, A]] otherwise:
def attempt[A](fa: F[A]): F[Either[E, A]]
The methods recover and recoverWith allow to handle only a specific subset of errors:
def recover[A](fa: F[A])(pf: PartialFunction[E, A]): F[A]
def recoverWith[A](fa: F[A])(pf: PartialFunction[E, F[A]]): F[A]
Below we return IO(-1) in case of only ArithmeticException:
def calculate(f: => Int): IO[Int] =
IO(f).recover {
case _: ArithmeticException => -1
}
With adaptError we may skip error handling altogether and simply transform the error or execute some action instead:
def adaptError[A](fa: F[A])(pf: PartialFunction[E, E]): F[A]
If the effect fa contains the error E, adaptError catches it and returns the error of the same type E. For instance, we can lift the original error to some domain error CalculationException:
def calculate(f: => Int): IO[Int] =
IO(f).adaptError {
case e: Throwable => new CalculationException(reason = Option(e.getMessage))
}
By means of the onError method, we can check whether the effect F[A] failed and execute some action before rethrowing the error:
def onError[A](fa: F[A])(pf: PartialFunction[E, F[Unit]]): F[A]
If the error doesn’t match, it’s simply rethrown. However, there’s one caveat with IO: an unmatched error of type Throwable remains unhandled, and we can’t handle it further!
In the following example, we have a callback for ArithmeticException inside onError, while NumberFormatException is left unhandled:
def calculate(f: => Int): IO[Int] =
IO(f).onError {
case _: ArithmeticException => logger.error("ArithmeticException")
}.handleError {
case _: ArithmeticException => -1
case _: NumberFormatException => -2
}
The line case _: NumberFormatException => -2 will be unreachable code if onError doesn’t catch it, and the following test fails:
calculate("5 * 3".toInt).unsafeRunSync() should be -2
Instead, this test just produces the error:
scala.MatchError: java.lang.NumberFormatException: For input string: "5 * 3" (of class java.lang.NumberFormatException) (of class scala.MatchError)
Meanwhile, the following assertion passes since onError catches ArithmeticException:
calculate(5 / 0).unsafeRunSync() shouldBe -1
Lastly, there are four utility methods in ApplicativeError to raise errors from some Scala’s standard library and Cats types: fromTry, fromEither, fromOption and fromValidated.
fromTry raises an error in case of Failure(e) :
def fromTry[A](t: Try[A])(implicit ev: Throwable <:< E): F[A]
fromEither raises an error in case of Left(e) :
def fromEither[A](x: Either[E, A]): F[A]
fromOption raises an error in case of None :
def fromOption[A](oa: Option[A], ifEmpty: => E): F[A]
fromValidated raises an error in case of Invalid(e) :
def fromValidated[A](x: Validated[E, A]): F[A]
These methods return the purified value F[A] if they’re invoked on successful values: Success[A], Right[E, A], Some[A] and Valid[A] respectively.
The cats.effect.IO‘s companion object comprises methods with identical names and semantics for our convenience, with the exception of fromValidated.
3.6. MonadError: When and Why?
Frequently, we may find it necessary to incorporate the capabilities of the Monad type class into our error-handling toolbox for the effect F[_].
One such scenario is when we need to return a value if a certain predicate is true and raise an error otherwise. Residing in the boundaries of an Applicative Functor, the task becomes non-trivial. However it becomes straightforward with the capabilities of the FlatMap or Monad type classes:
calculator[IO].calculate(5 / 1).flatMap {
case x if predicate => x.pure[IO]
case _ => new RuntimeException("Bad result").raiseError[IO, Int]
}
This idea lies at the core of the ensure method of MonadError:
def ensure[A](fa: F[A])(error: => E)(predicate: A => Boolean): F[A]
Another scenario involves unwrapping from Either[E, A] within effects of type F[Either[E, A]] to obtain refined F[A], rethrow:
def rethrow[A, EE <: E](fa: F[Either[EE, A]]): F[A]
This is the inverse of the attempt we previously saw in ApplicativeError:
def calculate[F[_] : Applicative](f: => Int)
(implicit ae: ApplicativeError[F, Throwable]): F[Either[Throwable, Int]] = ae.fromTry(Try(f)).attempt
def calculateSafely[F[_]](f: => Int)(implicit me: MonadThrow[F]): F[Int] =
calculate(f).rethrow.handleErrorWith {
case _: ArithmeticException => (-1).pure[F]
case _: NumberFormatException => (-2).pure[F]
}
Another valuable method offered by MonadError is redeemWith, which combines error recovery with binding function to the successful value:
def redeemWith[A, B](fa: F[A])(recover: E => F[B], bind: A => F[B]): F[B]
Finally, MonadError enables us to return the error value only if an error occurred and return the successful value otherwise. This capability is accessible through attemptTap:
def attemptTap[A, B](fa: F[A])(f: Either[E, A] => F[B]): F[A]
def calculate[F[_] : Applicative](f: => Int)
(implicit ae: ApplicativeError[F, Throwable]): F[Int] =
ae.fromTry(Try(f))
def calculateOrRaise[F[_]](f: => Int)(implicit me: MonadThrow[F]): F[Int] = calculate(f).attemptTap {
case Left(_) => new RuntimeException("Calculation failed").raiseError[F, Unit]
case Right(_) => ().pure[F]
}
4. Case Study: Using Domain Errors for Error Handling
Let’s examine the possibility of dealing with only a subset of errors in our code:
sealed trait DomainError extends NoStackTrace
case object NotFound extends DomainError
case object InvalidInput extends DomainError
Unfortunately, operating exclusively with these errors isn’t as easy as invoking an implicit m: MonadError[F, DomainError]. Such an implicit doesn’t exist by default, and we would need to override methods of ApplicativeError, FlatMap, and Applicative (i.e., raiseError, handleErrorWith, pure, flatMap, and tailRecM) to construct it. This approach results in cumbersome code, and there is an alternative way to constrain the error possibilities using a homemade data type, such as RaiseCustomError:
trait RaiseCustomError[F[_]] {
def raiseCustomError[A](e: DomainError): F[A]
}
object RaiseCustomError {
implicit def instance[F[_]](implicit M: MonadError[F, Throwable]): RaiseCustomError[F] =
new RaiseCustomError[F] {
def raiseCustomError[A](e: DomainError): F[A] = M.raiseError(e)
}
}
def serve[F[_] : Applicative](inOpt: Option[String])(implicit R: RaiseCustomError[F]): F[String] =
inOpt match {
case None => R.raiseCustomError(NotFound)
case Some(in) if in.isEmpty => R.raiseCustomError(InvalidInput)
case Some(in) => in.pure[F]
}
At present, we employ an implicit instance of RaiseCustomError to raise errors and safeguard against arbitrary errors.
This approach involves working solely with the capabilities of the Cats library. However, with the assistance of libraries like tofu, we can address these and other error-handling challenges which are out of our introductory scope.
5. Common Issues With Error Handling in Cats Effect
In this section, we’ll discuss some common issues with error handling in Cats and Cats Effect.
5.1. Instant Handling of Errors
We may have the following function which may raise an error:
def calculate[F](f: => Int)(implicit ae: ApplicativeError[F, Throwable]): F[Int] = ae.fromTry(Try(f))
Then, in the event of an error, we can’t assign the effect to some value res and handle errors afterward:
val res = calculate[IO](5 / 0)
res.handleErrorWith {
case _: ArithmeticException => IO.pure(-1)
case _: NumberFormatException => IO.pure(-2)
}
Instead, we need to call handleErrorWith right away:
val res = calculate[IO](5 / 0).handleErrorWith {
case _: ArithmeticException => IO.pure(-1)
case _: NumberFormatException => IO.pure(-2)
}
This applies not only for IO but also for other types as well.
5.2. Returning the Error of Recovery Method
In the following example, we call recoverWith method on the calculate(f) effect, but during the recovery itself, another error is thrown. This error is eventually returned by calculateOrRecover:
def recover[F[_]]()(implicit ae: ApplicativeError[F, Throwable]): F[Int] =
new RuntimeException("Calculation failed").raiseError[F, Int]
def calculateOrRecover[F[_]](f: => Int)(implicit me: MonadThrow[F]): F[Int] = calculate(f).recoverWith {
case _: ArithmeticException => recover()
case _: NumberFormatException => recover()
}
Such behavior is actually an adaptation of the error (see adaptError). It’s crucial to be aware of this because we lose the initial error information, which is undesirable, particularly when the error isn’t logged.
The general guideline to prevent such pitfalls is to include failure scenarios of the business logic in the tests and not restrict the tests solely to happy paths.
6. Conclusion
In this article, we learned the basics of error raising and error handling using ApplicativeError and MonadError from the Cats library, with applications to abstract effect F[_] and to IO from Cats Effect.
We explored situations where ApplicativeError isn’t enough for our error-handling needs.
Finally, we reviewed the use of custom errors in our code, along with potential pitfalls related to error handling we should be aware of.
As usual, the full code for this article is available over on GitHub.