Skip to main content
Scala: Handling Errors in ZIO
  1. Posts/

Scala: Handling Errors in ZIO

Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

In this post I’m exploring ZIO in ZIO course | Rock the JVM.

Error Basics
#

Failures
#

ZIOs can fail in multiple ways:

// Basic failure with custom error type
val aFailedZIO: IO[String, Nothing] = 
  ZIO.fail("Something went wrong")

// Failure with throwable
val failedWithThrowable: IO[RuntimeException, Nothing] = 
  ZIO.fail(new RuntimeException("Boom"))

// Transform error types using mapError
val failedWithDescription: ZIO[Any, String, Nothing] = 
  failedWithThrowable.mapError(_.getMessage)

Unsafe Code
#

What happens when exceptions are thrown inside ZIO.succeed

// Exceptions are not captured 
// in the ZIO error channel
val badZIO: ZIO[Any, Nothing, Int] = ZIO.succeed {
  println("Trying Something")
  val string: String = null
  string.length // This will throw, but ZIO doesn't expect it
}

ZIO.attempt
#

When you’re not sure if code will throw exceptions, use ZIO.attempt:

// This properly captures exceptions in the error channel
// This uses Task, which is ZIO[Any, Throwable, Int]
val anAttempt: Task[Int] = ZIO.attempt {
  println("Trying Something") 
  val string: String = null
  string.length
}

Error Recovery
#

.catchAll()
#

Effectfully catch errors - the end result will also be an effect. catchAll recovers from error by transforming it into another effect.

Recovering from failures using different effect types:

// Recover with another effect that might fail
// Error type remains Throwable
val catchError: ZIO[Any, Throwable, Any] = 
  anAttempt.catchAll(e => 
    ZIO.attempt(s"Returning a different value because $e")
  )

// Recover with a guaranteed success
// Error type becomes Nothing
val catchErrorSuccess: ZIO[Any, Nothing, Any] = 
  anAttempt.catchAll(e => 
    ZIO.succeed(s"Returning a different value because $e")
  )

.catchSome()
#

Selectively handle only certain error types using pattern matching

// Handle only specific error types
// Error channel remains Throwable because it cannot be 
// guaranteed at compile time that it will not fail
val catchSelectiveError: ZIO[Any, Throwable, Any] = 
  anAttempt.catchSome {
    case e: RuntimeException => 
      ZIO.succeed(s"Ignoring Runtime Exceptions: $e")
    case _ => 
      ZIO.succeed("Ignoring Everything else")
  }

// Error type can be broadened when mixing different error types
// catchSome finds the lowest common denominator 
// for all returned error types
val catchSelectiveError_v2: ZIO[Any, Serializable, Any] = 
  anAttempt.catchSome {
    case e: RuntimeException => 
      ZIO.succeed(s"Ignoring Runtime Exceptions: $e")
    case _ => 
      ZIO.fail("Ignoring Everything else")
  }

.orElse()
#

Providing a fallback ZIO when the first one fails:

// orElse completely overrides the error channel
val aBetterAttempt: ZIO[Any, Nothing, Int] = 
  anAttempt.orElse(ZIO.succeed(56))

Result Handling
#

.fold()
#

The fold signature takes two functions:

  • one for failure
  • one for success:
def fold[B](
  failure: E => B, 
  success: A => B
)(implicit ev: CanFail[E], trace: Trace): URIO[R, B]
val handleBoth: URIO[Any, String] = anAttempt.fold(
  ex => s"Something bad happened: $ex",      
  // handle failed
  value => s"length of the string was $value" 
  // handle success
)

.foldZIO()
#

Effectful fold: both success and failure handlers return ZIO effects.

The foldZIO signature allows both handlers to return effects:

def foldZIO[R1 <: R, E2, B](
  failure: E => ZIO[R1, E2, B], 
  success: A => ZIO[R1, E2, B]
)(implicit ev: CanFail[E], trace: Trace): ZIO[R1, E2, B]
val handleBoth_v2: ZIO[Any, Nothing, String] = anAttempt.foldZIO(
  ex => ZIO.succeed(s"Something bad happened: $ex"),      
  // handle failed
  value => ZIO.succeed(s"length of the string was $value") 
  // handle success
)

Conversions
#

Try → ZIO
#

Converting Try to ZIO creates a Task with Throwable error type. The result type is ZIO[Any, Throwable, Int]:

val aTryToZIO: Task[Int] = ZIO.fromTry(Try(42 / 0))

Either → ZIO
#

Converting Either where Left becomes error and Right becomes success. The result is an IO[Int, String] which is ZIO[Any, Int, String]:

val anEither: Either[Int, String] = Right("Success!")
val anEitherToZIO: IO[Int, String] = ZIO.fromEither(anEither)

ZIO → Either
#

Transform ZIO into either success or failure in the value channel. The .either method produces a URIO[Any, Either[Throwable, Int]] which is ZIO[Any, Nothing, Either[Throwable, Int]]:

val eitherZIO: URIO[Any, Either[Throwable, Int]] = anAttempt.either

// Place Left of Either back into the Error Channel
val anAttempt_v2: ZIO[Any, Throwable, Int] = eitherZIO.absolve

Option → ZIO
#

Converting Option where None becomes a failure. The result type is IO[Option[Nothing], Int] which is ZIO[Any, Option[Nothing], Int]:

val anOption: IO[Option[Nothing], Int] = ZIO.fromOption(Some(42))

Implementation Details
#

Those conversions can be done manually using pattern matching:

// Custom Try to ZIO conversion
def try2ZIO[A](aTry: Try[A]): Task[A] = aTry match {
  case Failure(exception) => ZIO.fail(exception)
  case Success(value) => ZIO.succeed(value)
}

// Custom Either to ZIO conversion
def either2ZIO[A, B](anEither: Either[A, B]): IO[A, B] = anEither match {
  case Left(value) => ZIO.fail(value)
  case Right(value) => ZIO.succeed(value)
}
// Custom ZIO to ZIO Either conversion using foldZIO
def zio2ZioEither[R, A, B](
  zio: ZIO[R, A, B]
): ZIO[R, Nothing, Either[A, B]] = 
  zio.foldZIO(
    error => ZIO.succeed(Left(error)),
    value => ZIO.succeed(Right(value))
  )

// Custom absolve implementation
def absolveZIO[R, A, B](
  zio: ZIO[R, Nothing, Either[A, B]]
): ZIO[R, A, B] = 
  zio.flatMap {
    case Left(e) => ZIO.fail(e)
    case Right(v) => ZIO.succeed(v)
  }
// Custom Option to ZIO conversion
def option2ZIO[A](
  anOption: Option[A]
): IO[Option[Nothing], A] = anOption match {
  case Some(value) => ZIO.succeed(value)
  case None => ZIO.fail(None)
}