A practical guide to error handling in Scala Cats and Cats Effect
This article is meant to be a helpful reference of error handling methods for developers having some experience with FP programming and the Scala Cats ecosystem specifically. It’s not intended to provide a thorough introduction to error handling in Scala Cats and Cats Effect.
Introduction (short)
In functional programming, we don’t like code throwing exceptions as it breaks referential transparency. Instead, we prefer types that explicitly provide information that a value can be either success or failure. In the Scala standard library, such types are e.g. Either and Try.
Scala Cats defines two type classes that provide methods useful in error handling, i.e. ApplicativeError and MonadError. Leveraging the type class pattern, Scala Cats extends Scala standard library classes like Either or Try providing standardized methods for error handling despite the type we actually use. Furthermore, Cats Effects is built on top of that concept and type classes defined by Cats Effects extend MonadError from Scala Cats (and MonadError extends ApplicativeError). Therefore, although the Either and Try types are inherently different from effect types like IO provided by Cats Effect, we can use the same methods to handle errors for all these types. Of course, we are not limited to Try, Either, and IO to represent a potentially failed value, other such examples are EitherT monad transformer from Scala Cats or Task from Monix.
Error type
MonadError doesn’t assume anything about the type representing an error, specifically, it doesn’t have to be a JVM exception (i.e. descendant of Throwable). However, among the three types discussed here i.e. Try, Either, and IO, only Either allows to define a custom error type, for Try and IO, the error is represented always by Throwable. Thus, for compatibility (easy conversion between Try, Either, and IO), it is common to limit the use of Either to Either [Throwable, A] i.e. defining type F [A] = Either [Throwable, A]. According to this logic, Scala Cats defines an alias for MonadError instances representing error with Throwable, i.e. MonadThrow. With rare exceptions, all methods presented here can be used for any type for which a MonadThrow instance is available.
Error handling
The table below presents all methods provided by MonadError and Cats Effect’s MonadCancel that I find useful for error handling. To make it easier to find the one that fits your needs best, I divided them into 7 categories. I also provided an example for each method. If you find the examples difficult to read or you’d like to run them by yourself, please read this.
Recover from failure
Failure => Success
What is your need? | Use one of these | Remarks |
---|---|---|
Replace error with a value | orElse | expects wrapped value |
forceR | only effect types (e.g. IO) | |
Map every error to a value | handleError | |
handleErrorWith | expects wrapped value | |
Map every error or a value to another value | redeem | |
redeemWith | expects wrapped value | |
Map selected errors to a value with pattern matching | recover | |
recoverWith | expects wrapped value | |
Map error to None | option | only IO |
See also | attempt, attemptT, attemptNarrow |
Recover from error by shifting the error to the value (and back)
Failure <=> Success(Either)
What is your need? | Use one of these | Remarks |
---|---|---|
Shift error downward into value | attempt | |
attemptT | ||
attemptNarrow | ||
Shift error upward into F | rethrow | |
Turn success into failure if the value is invalid
Success => Failure
What is your need? | Use one of these | Remarks |
---|---|---|
Validate value with a boolean function | ensure | |
ensureOr | ||
Validate with pattern matching | reject | |
See also | rethrow | |
Transform error Failure => Failure
What is your need? | Use one of these | Remarks |
---|---|---|
Replace error with a given error | orRaise | |
Transform selected errors with pattern matching | adaptError, adaptErr | |
Seel also | orElse, handleErrorWith, redeemWith, recoverWith | usually used for recovery, but can return failure as well |
Create failure value
=> Failure
What is your need? | Use one of these | Remarks |
---|---|---|
Create failure value | raiseError | called on a type class instance or instance of an error |
Conditionally create failure value | raiseWhen | returns F[Unit] |
raiseUnless | returns F[Unit] |
Catch exceptions
throw => F
What is your need? | Use one of these | Remarks |
---|---|---|
Catch all non-fatal exceptions | catchNonFatal | |
catchNonFatalEval | ||
Catch selected exception | catchOnly | |
Execute a side effect (logging)
Failure/Success => …
↓↓
log.error()
What is your need? | Use one of these | Remarks |
---|---|---|
Execute a side effect (logging) on either failure or success | attemptTap | |
Execute a side effect (logging) only on failure | onError |
About the examples
All examples were compiled and tested with Scala Cats 2.7.0, Cats Effect 3.3.0, and Scala 2.13.
Symbols used in the examples:
- F type - type representing a value that can be a success or a failure, all examples were compiled with F defined as:
type F[A] = Either[Throwable, A]
but they should also work with one of:
type F[A] = Try[A]
type F[A] = IO[A]
- F value - instance of MonadThrow[F], can be summoned like this:
val F = MonadThrow[F]
- f(a) - value “a” wrapped by F, can be defined as
def f[T](a:T):F[T] = F.pure(a)
To run examples by yourself, you will need (besides the above definitions) to add Scala Cats and CatsEffect dependences and the following imports:
import cats.MonadThrow
import cats.effect.IO
import cats.syntax.all._
import scala.util._
Recover from failure
Methods in this category allow you to transform a failed value into a success value, they are all ignored if the value is already a success (with an exception for redeem, redeemWith, and option). All methods that expect a function that yields value wrapped into F can be used for selective recovery or error transformation as the function can return either failure or success value.
orElse
Replaces error regardless of its error type with the given value. The provided value has to be wrapped into F and can be a failure as well, so it can be used to replace an existing error with the given one.
scala> F.raiseError(new RuntimeException).orElse(f(2))
val res4: scala.util.Either[Throwable,Int] = Right(2)
scala> F.raiseError(new RuntimeException).orElse(F.raiseError(new Exception))
val res5: scala.util.Either[Throwable,Nothing] = Left(java.lang.Exception)
Partner with Scala Experts to build complex applications efficiently and with improved code accuracy. Working code delivered quickly and confidently. Explore the offer >>
forceR
Replaces failure regardless of the error type with a success value of an arbitrary type. The provided value has to be wrapped into F. This method is provided by the Cats Effect type class MonadCancel, so it’s available only for effect types like IO.
scala> IO.raiseError[Int](new RuntimeException).forceR(IO("a"))
val res0: cats.effect.IO[String] = IO(...)
handleError
Allows you to map an error into a success value of the same type.
scala> F.raiseError[String](new RuntimeException("Error!"))
| .handleError(
| error => error.getMessage
| )
val res6: F[String] = Right(Error!)
handleErrorWith
The “F” counterpart of handleError, the value returned by the mapping function has to be wrapped into F (can be a failure or a success).
scala> F.raiseError[String](new RuntimeException("Error!"))
| .handleErrorWith(
| error => f(error.getMessage)
| )
val res11: F[String] = Right(Error!)
scala> F.raiseError[String](new Exception("Error!"))
| .handleErrorWith {
| case error: RuntimeException => f(error.getMessage)
| case other => F.raiseError(other)
| }
val res12: F[String] = Left(java.lang.Exception: Error!)
redeem
Redeem combines handleError and map into one operation, you have to provide two mapping functions, one of them will be evaluated depending on whether the value is a success or a failure, the recovery value can be of an arbitrary type.
scala> f(1).redeem(
| error => error.getMessage,
| value => s"Success: [$value]"
| )
val res13: F[String] = Right(Success: [1])
redeemWith
The “F” counterpart of redeem, a value returned by both mapping functions has to be wrapped into F (can be a failure or a success).
scala> F.raiseError[String](new Exception("Error!")).redeemWith(
| error => F.raiseError[String](new RuntimeException(error.getMessage)),
| value => f(s"Success: [$value]")
| )
val res14: F[String] = Left(java.lang.RuntimeException: Error!)
recover
Recover expects partial function enabling convenient selective recovery using pattern matching. Failure values not matching any clause will remain a failure.
scala> case class MyException(value: String) extends Exception
class MyException
scala> F.raiseError[String](MyException("a value")).recover {
| case MyException(value) => value
| }
val res15: Either[Throwable,String] = Right(a value)
scala> F.raiseError[String](new RuntimeException).recover {
| case MyException(value) => value
| }
val res16: Either[Throwable,String] = Left(java.lang.RuntimeException)
recoverWith
The “F” counterpart of recover, a value returned by the mapping function has to be wrapped into F (can be a failure or a success).
scala> F.raiseError[String](MyException("a value")).recoverWith {
| case MyException(value) => f(value)
| }
val res2: Either[Throwable,String] = Right(a value)
option
Converts failed IO into IO(None) and success IO(a) into IO(Some(a)). This method is not provided by a type class, it is implemented directly by the IO class, so it is available only for IO.
scala> IO.raiseError[Int](new Exception).option
val res23: cats.effect.IO[Option[Int]] = IO(...)
Recover from error by shifting the error to the value and back
Methods in this category affect failure and success values, they allow conversion from either success or failure into a success of Either type.
attempt
Converts success value F(a) into F(Right(a)) and failure into F(Left(error)), the result is always a success value.
scala> f(1).attempt
val res18: F[Either[Throwable,Int]] = Right(Right(1))
scala> F.raiseError[Int](new Exception).attempt
val res19: F[Either[Throwable,Int]] = Right(Left(java.lang.Exception))
attemptT
Similar to attempt, but returns a value wrapped into EitherT monad transformer, you can read more about monad transformers here.
scala> F.raiseError[Int](new Exception).attemptT
val res20: cats.data.EitherT[F,Throwable,Int] = EitherT(Right(Left(java.lang.Exception)))
attemptNarrow
Similar to attempt, but returns a success only if the error is of a provided type, otherwise, it returns unaffected failure value.
scala> F.raiseError[Int](new Exception).attemptNarrow[RuntimeException]
val res21: F[Either[RuntimeException,Int]] = Left(java.lang.Exception)
scala> F.raiseError[Int](new RuntimeException).attemptNarrow[RuntimeException]
val res22: F[Either[RuntimeException,Int]] = Right(Left(java.lang.RuntimeException))
rethrow
The reverse of attempt converts a successful value of Either into a success or failure value depending on the input Either being Left or Right.
scala> f((new Exception).asLeft[Int]).rethrow
val res24: F[Int] = Left(java.lang.Exception)
scala> f(1.asRight).rethrow
val res25: F[Int] = Right(1)
Turn success into failure if the value is invalid
Methods in this category are used for validation, in the case of unsuccessful validation, the value is turned into a failure. For failure values, these methods are ignored.
ensure
Check if a value satisfies the given condition, if not, then fail with the given error.
scala> f(1).ensure(new RuntimeException)(
| a => a > 0
| )
val res26: Either[Throwable,Int] = Right(1)
scala> f(-1).ensure(new RuntimeException)(
| a => a > 0
| )
val res27: Either[Throwable,Int] = Left(java.lang.RuntimeException)
ensureOr
Similar to ensure but allows to map the current value to an error.
scala> f(-1).ensureOr(
| a => new RuntimeException(s"Expected value >0, got: $a")
| )(
| a => a > 0
| )
val res28: Either[Throwable,Int] =
Left(java.lang.RuntimeException: Expected value >0, got: -1)
reject
Reject expects partial function enabling convenient selective rejection using pattern matching. Success values not matching any clause will remain a success.
scala> "a".asRight[Throwable].reject {
| case "b" => new RuntimeException
| }
val res45: Either[Throwable,String] = Right(a)
Transform error
Methods in this category are used to translate an error of failure value, they are ignored for success values.
orRaise
Replaces any error with the given error, see also orElse if you want to replace with an F wrapped value.
scala> F.raiseError[Int](new Exception).orRaise(new RuntimeException)
val res31: F[Int] = Left(java.lang.RuntimeException)
adaptError adaptErr
Expects partial function enabling convenient selective error transformation using pattern matching. Unmatched errors remain untouched. Both methods do the same, the difference is that adaptErr is provided by ApplicativeError type class and adapError by MonadError, in most cases, it doesn’t matter which you choose.
scala> F.raiseError[Int](new RuntimeException("Error message")).adaptError {
| case e: RuntimeException => new Exception(e.getMessage)
| }
val res32: F[Int] = Left(java.lang.Exception: Error message)
Create failure value
Methods in this category are used to create a failure value. The most universal way to use them is to use a MonadError (or MonadThrow) type class instance. With that instance, you can create a failure value in the same way regardless of the actual type used to represent a value. You can, of course, still use methods provided by specific types like Left(error) for Either or Failure(error) for Try.
raiseError
Creates failed value from the given error. Can be called either on an instance of ApplicativeError (or its descendant) type class or an instance of an error or the IO object. Returns value of an arbitrary type, sometimes you may need to provide the type of wrapped value if the compiler can’t infer it.
scala> F.raiseError[Int](new RuntimeException)
val res33: F[Int] = Left(java.lang.RuntimeException)
scala> (new RuntimeException).raiseError[F, Int]
val res34: F[Int] = Left(java.lang.RuntimeException)
scala> IO.raiseError[Int](new RuntimeException)
val res35: cats.effect.IO[Int] = IO(...)
raiseWhen raiseUnless
Creates a failed value when a condition is true (when) or false (unless), otherwise returns Unit wrapped into F, the return type is always F[Unit]. Can be called either on an instance of ApplicativeError (or its descendant) or the IO object.
scala> F.raiseWhen(false)(new RuntimeException)
val res37: F[Unit] = Right(())
scala> F.raiseUnless(false)(new RuntimeException)
val res38: F[Unit] = Left(java.lang.RuntimeException)
scala> IO.raiseWhen(true)(new RuntimeException)
val res39: cats.effect.IO[Unit] = IO(...)
scala> IO.raiseUnless(true)(new RuntimeException)
val res40: cats.effect.IO[Unit] = IO(())
Catch exceptions
Methods from this category are used to interface with imperative code that can throw an exception, they allow to convert the result of an expression into a success or failure value (if an exception was thrown).
catchNonFatal
Catches all (non-fatal) exceptions. Can be called on an instance of ApplicativeError (or its descendant) or on the Either object (after importing either syntax from Scala Cats).
scala> F.catchNonFatal(sys.error("Error!"))
val res41: F[Nothing] = Left(java.lang.RuntimeException: Error!)
scala> Either.catchNonFatal(sys.error("Error!"))
val res54: Either[Throwable,Nothing] = Left(java.lang.RuntimeException: Error!)
catchNonFatalEval
Similar to catchNonFatal, but the expression has to be wrapped into Eval.
scala> F.catchNonFatalEval(cats.Eval.defer(sys.error("Error!")))
val res42: F[Nothing] = Left(java.lang.RuntimeException: Error!)
catchOnly
Similar to catchNonFatal, but catches only selected exception (and its descendants). Warning, other exceptions are being rethrown!
scala> F.catchOnly[RuntimeException](throw new Exception)
java.lang.Exception
at $anonfun$res58$1(<console>:1)
at cats.ApplicativeError$CatchOnlyPartiallyApplied$.apply$extension(ApplicativeError.scala:337)
... 35 elided
Execute a side effect logging
Methods in this category are not actually used for error handling, probably the most common case is logging an error, they usually leave the original value unaffected.
attemptTap
Calls the side effect passing result of the attempt method called on the original value. The side effect is called for success and failure values. The original value stays unaffected unless the side effect returns a failure, then the result is a failure with the error returned by the side effect.
scala> f(1).attemptTap(
| errorEitherValue => f(println(s"log: $errorEitherValue"))
| )
log: Right(1)
val res43: F[Int] = Right(1)
scala> F.raiseError[Int](new Exception).attemptTap(
| errorEitherValue => f(println(s"log: $errorEitherValue"))
| )
log: Left(java.lang.Exception)
val res44: F[Int] = Left(java.lang.Exception)
scala> f(1).attemptTap(
| errorEitherValue => F.raiseError(new Exception("Side effect error"))
| )
val res5: F[Int] = Left(java.lang.Exception: Side effect error)
onError
Calls the side effect only for a failure value, passing error as the parameter, if the side effect returns a failure value, the error of the original value is replaced with the error of the side effect (with the exception for IO, which preserves the original error).
scala> F.raiseError[Int](new Exception).onError(
| errorEitherValue => f(println(s"log: $errorEitherValue"))
| )
log: java.lang.Exception
val res3: F[Int] = Left(java.lang.Exception)
scala> f(1).onError(
| errorEitherValue => F.raiseError(new Exception)
| )
val res45: F[Int] = Right(1)
Summary
Thank you for reading, I hope you find this guide useful. Please leave me a comment if there's something missing in this summary. And if you want more of this kind of content - subscribe to our Scala Times Newsletter here!