Contents

A practical guide to error handling in Scala Cats and Cats Effect

A practical guide to error handling in Scala Cats and Cats Effect webp image

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 theseRemarks
Replace error with a valueorElseexpects wrapped value
forceRonly effect types (e.g. IO)
Map every error to a valuehandleError
handleErrorWithexpects wrapped value
Map every error or a value to another valueredeem
redeemWithexpects wrapped value
Map selected errors to a value with pattern matchingrecover
recoverWithexpects wrapped value
Map error to Noneoptiononly IO
See alsoattempt, attemptT, attemptNarrow

Recover from error by shifting the error to the value (and back)
Failure <=> Success(Either)

What is your need?Use one of theseRemarks
Shift error downward into valueattempt
attemptT
attemptNarrow
Shift error upward into Frethrow

Turn success into failure if the value is invalid
Success => Failure

What is your need?Use one of theseRemarks
Validate value with a boolean functionensure
ensureOr
Validate with pattern matchingreject
See alsorethrow

Transform error Failure => Failure

What is your need?Use one of theseRemarks
Replace error with a given errororRaise
Transform selected errors with pattern matchingadaptError, adaptErr
Seel alsoorElse, handleErrorWith, redeemWith, recoverWithusually used for recovery, but can return failure as well

Create failure value
=> Failure

What is your need?Use one of theseRemarks
Create failure valueraiseErrorcalled on a type class instance or instance of an error
Conditionally create failure valueraiseWhenreturns F[Unit]
raiseUnlessreturns F[Unit]

Catch exceptions
throw => F

What is your need?Use one of theseRemarks
Catch all non-fatal exceptionscatchNonFatal
catchNonFatalEval
Catch selected exceptioncatchOnly

Execute a side effect (logging)
Failure/Success => …
↓↓
log.error()

What is your need?Use one of theseRemarks
Execute a side effect (logging) on either failure or successattemptTap
Execute a side effect (logging) only on failureonError

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)

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!

Blog Comments powered by Disqus.