Skip to Content
Course content

122: Error Handling in ZIO

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When I first started moving from standard Scala to ZIO, I had a habit I couldn't shake: I kept wrapping everything in try-catch blocks. I figured that since ZIO effects are just descriptions of programs, I should protect those descriptions with the same tools I used for imperative code. If a database call might throw an SQLException, I'd just wrap the call in a try block inside a ZIO.attempt. It felt safe. It felt familiar.

The Try-Catch Safety Net Illusion

Here is the problem: when you use a native try-catch inside a ZIO effect, you are essentially hiding the error from the ZIO runtime and the type system. Look at this snippet:

def fetchUser(id: String): ZIO[Any, Nothing, User] = {
  ZIO.attempt {
    try {
      repository.findUser(id) 
    } catch {
      case e: Exception => 
        println(s"Log: user $id not found")
        User.Guest // Return a fallback value
    }
  }
}

At first glance, this looks fine. But look at the return type: ZIO[Any, Nothing, User]. I've told the compiler—and any other developer using this method—that this effect cannot fail (that's what Nothing in the error channel means). I've effectively lied to the type system. If the fallback User.Guest isn't actually appropriate for the business logic downstream, the calling code has no way to know that a failure occurred. We've traded type safety for a feeling of familiarity, and in a large system, that's how bugs sneak into production.

Embracing the Error Channel

In ZIO, we don't hide errors; we promote them. The E in ZIO[R, E, A] is your best friend. Instead of catching the error immediately to return a "safe" value, we lift the exception into the error channel and let the caller decide how to handle it. I prefer defining a sealed trait for domain errors so the compiler can help me ensure I've handled every possible failure case.

Let's rewrite that user fetch using a proper domain error:

sealed trait UserError
case class UserNotFound(id: String) extends UserError
case class DatabaseError(cause: Throwable) extends UserError

def fetchUser(id: String): ZIO[Any, UserError, User] = {
  ZIO.attempt(repository.findUser(id))
    .mapError {
      case e: NoSuchElementException => UserNotFound(id)
      case other => DatabaseError(other)
    }
}

Now the signature is honest: ZIO[Any, UserError, User]. The person calling this function is now forced to deal with UserError. If they want to provide a fallback, they can use .catchAll or .orElse. This keeps the "happy path" clean while making the "sad path" explicit.

I want to highlight two key combinators you'll use constantly: catchAll and catchSome. Use catchAll when you have a general recovery strategy for any error of type E. Use catchSome when you only care about one specific failure—say, retrying on a Timeout but crashing on a PermissionDenied.

One last tip: avoid using ZIO.attempt for everything. If you are already inside the ZIO world and you want to trigger a failure intentionally based on a business condition, use ZIO.fail(error). It's cleaner, it's faster, and it doesn't involve the overhead of creating a JVM exception stack trace.




📋 Practical Task

Build a Resilient Payment Gateway Handler

You are integrating a third-party payment API. The API is flaky: it sometimes throws a TimeoutException, and occasionally it returns a PaymentDeclinedException. You need to implement a handler that follows these specific business rules:

  • If a TimeoutException occurs, the system should retry the operation exactly 3 times before finally failing with a GatewayTimeout domain error.
  • If a PaymentDeclinedException occurs, it should not retry; it should immediately fail with a TransactionDenied domain error.
  • Any other unexpected exception should be mapped to a general InternalSystemError.

Requirements:

  1. Define a sealed trait PaymentError with the three cases mentioned above.
  2. Create a function processPayment(amount: Double): ZIO[Any, PaymentError, Receipt].
  3. Use ZIO.attempt to wrap the flaky API call.
  4. Use .retry for the timeout logic and .catchAll or .mapError to transform the exceptions into your domain PaymentError types.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.