ZIO 2: Error-Handling Decision Tree (Flowchart)

This page provides a comprehensive overview of error-handling strategies in ZIO 2. My hope is that you can use this decision tree to determine good/best approaches for handling errors in your ZIO effects. Each section includes a use case (question/answer), brief explanation, and ZIO 2 example.

As a brief note, I have ensured that the following examples compile, but I feel like I need to double-check some of my work.

ZIO 2 Error-Handling Decision Tree (Flowchart)

When using ZIO 2 error-handling, do you want to:

  1. perform an effect when an error occurs?
    → Use tapError
  2. transform the error?
    • Transform any error into another error
      → Use mapError
    • Transform any error into a successful value
      → Use orElse
    • Transform a non-error (e.g., None) into a failure
      → Use orElseFail
  3. catch and recover from errors?
    • Catch some types of errors
      → Use catchSome
    • Catch all types of errors
      → Use catchAll
  4. make errors visible but delay handling?
    → Use either
  5. retry the effect on failure?
    → Use retry
  6. fallback to another effect on failure?
    → Use orElse
  7. convert between error channels?
    → Use refineOrDie or refineOrDieWith
  8. handle both success and failure cases?
    → Use fold or foldZIO
  9. handle defects (fatal errors)?
    → Use foldCauseZIO
  10. compose multiple error-handling strategies?

The idea of this flowchart is that if you have the specific needs in situations 1-7, use those error-handling strategies. Otherwise, you’ll start to look at more general-purpose tools like foldZIO.

More information on each ZIO error-handling strategy is shown below.

1. Performing an Effect on Error (tapError)

Use when you want to perform a side effect when an error occurs, without changing the error or the success type:

val zioWithTapError: ZIO[Any, String, Int] =
    ZIO.fail("Error").tapError { err =>
        ZIO.succeed(println(s"Handled error: $err"))
    }

// output: Handled error: Error

2. Transforming Errors (mapError, orElse)

mapError

Use when you want to transform any error into another error type, such as converting a Throwable into a String:

val zioWithMapError: ZIO[Any, String, Int] =
    ZIO.fail(404).mapError { err =>
        s"Error code: $err"
    }

// result: ZIO fails with error "Error code: 404"

orElse (for error to success transformation)

Use when you want to transform any error into a successful value:

val zioWithOrElse: ZIO[Any, Nothing, Int] =
    ZIO.fail("Error")
       .orElse(ZIO.succeed(42))

// result: ZIO succeeds with value 42

orElseFail (Transforming Non-Errors to Failures)

Use when you want to convert a non-error state (like None in an Option) into a failure:

def convertOptionToZIO[A, E](option: Option[A], error: => E): ZIO[Any, E, A] =
    ZIO.fromOption(option)
       .orElseFail(error)

val result: ZIO[Any, String, Int] = convertOptionToZIO(None, "Value not found")
// result: ZIO fails with error "Value not found"

val success: ZIO[Any, String, Int] = convertOptionToZIO(Some(42), "Value not found")
// result: ZIO succeeds with value 42

3. Catching and Recovering from Errors (catchSome, catchAll)

catchSome

Use when you want to catch and recover from only specific types of errors:

val zioWithCatchSome: ZIO[Any, String, Int] =
    ZIO.fail("Critical").catchSome {
        case "Non-critical" => ZIO.succeed(42)
    }

// result: ZIO fails with "Critical" as it doesn't match the PartialFunction

catchAll

Use when you want to catch and recover from all types of errors:

val zioWithCatchAll: ZIO[Any, Nothing, Int] =
    ZIO.fail("Error").catchAll { _ =>
        ZIO.succeed(42)
    }

// result: ZIO succeeds with value 42 after catching the error

4. Delaying Error Handling (either)

Use when you want to make errors visible but delay their handling:

val zioWithEither: ZIO[Any, Nothing, Either[String, Int]] =
    ZIO.fail("Error")
       .either

// result: ZIO succeeds with Left("Error")

5. Retrying on Failure (retry)

Use when you want to retry the effect a certain number of times or according to a schedule:

val zioWithRetry: ZIO[Any, String, Int] =
    ZIO.fail("Error")
       .retry(Schedule.recurs(3))

// result: ZIO fails after 3 retries

6. Fallback Strategies (orElse (for fallback))

Use when you want to try another effect if the first one fails:

val zioWithFallback: ZIO[Any, Nothing, Int] =
    ZIO.fail("Error")
       .orElse(ZIO.succeed(99))

// result: ZIO succeeds with value 99

Note: orElse can be used both for transforming errors into successful values and as a fallback strategy. The key difference is in how you use it:

  • For error transformation: orElse(ZIO.succeed(defaultValue))
  • For fallback: orElse(anotherZIOEffect)

7. Converting Between Error Channels

refineOrDie

Use when you want to narrow the error type and convert unhandled errors into defects:

import java.io.IOException

val refined: ZIO[Any, IOException, String] =
    ZIO.fail(new Exception("boom"))
       .refineOrDie[IOException] {
           case io: IOException => io
       }

// result: ZIO fails with a Cause.Die containing the original Exception

refineOrDieWith

Similar to refineOrDie, but allows you to specify a function to handle the unmatched cases:

val refinedWith: ZIO[Any, IOException, Nothing] =
    ZIO.fail(Exception("boom"))
        .refineOrDieWith {
            case ioe: IOException => ioe
        }(e => new IOException(s"Converted: ${e.getMessage}"))

// result: ZIO fails with an IOException "Converted: boom"

8. Handling Both Success and Failure (fold, foldZIO)

fold

Use when you want to handle both success and failure cases synchronously in a single effect:

val folded: ZIO[Any, Nothing, String] = 
    ZIO.attempt(1 / 0).fold(
        err   => s"Failed: ${err.getMessage}",
        value => s"Succeeded: $value"
    )

// result: ZIO succeeds with "Failed: / by zero"

foldZIO

Use for more complex error-handling scenarios where you need different behavior for success and failure cases, potentially involving effects:

val foldedZIO: ZIO[Any, Nothing, String] =
    ZIO.fail("Error").foldZIO(
        err     => ZIO.succeed(s"Handled: $err"),
        success => ZIO.succeed(s"Success: $success")
    )

// result: ZIO succeeds with "Handled: Error"

9. Handling Defects (Fatal Errors)

foldCauseZIO

Use when you need to handle both recoverable errors and defects (fatal errors) in an effectful way:

import zio.Cause

val handled: ZIO[Any, Nothing, String] = 
    ZIO.die(new Error("Fatal error")).foldCauseZIO(
        cause => ZIO.succeed(s"Handled cause: ${cause.prettyPrint}"),
        value => ZIO.succeed(s"Success: $value")
    )

// result: ZIO succeeds with "Handled cause: [...]" (containing the error details)

This method lets you work with the full Cause, which includes all types of failures (recoverable or fatal), and to return new effects based on the cause or success.

10. Composing Error-Handling Strategies

As a final note, you can compose multiple error-handling strategies to create more complex error-handling logic. This example demonstrates how you can chain multiple error-handling strategies to create a robust error-handling pipeline:

val composedErrorHandling: ZIO[Any, Nothing, Int] =
    ZIO.fail("Initial error")
       .mapError(err => s"Mapped: $err")
       .catchSome { case "Mapped: Recoverable" => ZIO.succeed(1) }
       .retry(Schedule.recurs(2))
       .orElse(ZIO.succeed(42))

// result: ZIO succeeds with 42 after retrying and falling back

Here’s how this works:

  1. mapError is used to transform the error message.
  2. catchSome is used to recover from specific errors ("Mapped: Recoverable").
  3. retry attempts to retry the effect a certain number of times (in this case, 2 retries).
  4. orElse provides a fallback effect (returning 42 if the retries still result in failure).

Here, the final result will be ZIO.succeed(42) after exhausting the retries.

More information

For more information, see the ZIO “Handling Errors” page.