Error Handling in Scala: Navigating Try, Either, and ZIO

Dimitrije BulajaJun 19, 2026

A reliable Scala application requires thinking about failure from the start of its development. Whether you are integrating with external APIs, parsing user inputs, or managing database connections, errors will always occur in some way. The only question is how gracefully your application will handle these failures.

Scala provides several ways to handle a failure, each having a distinct philosophy about how code should respond to unexpected behaviour.

This post walks through three approaches: TryEither, and ZIO, showing what each of these approaches brings to the table, where it falls short, and when to reach for it.

The Trouble With Exceptions

If you’re coming from Javatry/catch may seem familiar, and your initial impulse might be to throw an exception when something fails, catching it later in the call stack. While this method functions, it imposes hidden costs.

The first issue is that exceptions break referential transparency. Consider a following example:

val x: Int = throw new Exception("fail!")

try {
  x + 1
} catch {
  case _: Exception => 0
}

This crashes before the try/catch block ever runs. But if you put the throw directly into the try, the exception gets caught, and you get 0 as result. Same expression with different results depending on where you write it. That’s referential transparency breaking down.

The second issue is that a function that returns an Int can throw exceptions, and nothing in the signature tells you that. This is probably the core problem with exceptions: they are invisible. Every caller has to either know the implementation details or hope for the best.

You can use checked exceptions, where the key premise is that the caller should handle the errors. But if you already worked with them, you know the result: a lot of try/catch blocks, exceptions swallowing, and eventually just wrapping everything in “throws Exception” to make the compiler stop complaining.

Functional programming offers a different approach to representing failure as a value rather than a disruption to control flow.

Try: Catching Exceptions as Values

scala.util.Try is a first step towards the goal. It wraps a computation that might throw an exception and returns either a Success(value) or a Failure(exception):

import scala.util.{Try, Success, Failure}

def parseInt(s: String): Try[Int] = Try(s.toInt)

val result = for {
  a <- parseInt("11")
  b <- parseInt("7")
} yield a + b
// result: Success(18)

val failedResult = for {
  a <- parseInt("hello")
  b <- parseInt("7")
} yield a + b
// result: Failure(java.lang.NumberFormatException)

The function returns Try[Int], which tells the caller explicitly that there is a chance this will not work. Try provides mapflatMaprecover, and getOrElse. You can compose computations that might fail, and use a for-comprehension that short-circuits on the first failure.

Try is perfect for situations where you’re wrapping some Java libraries or legacy code that throws exceptions. Unfortunately, Try has some clear limitations:

- The error type is always throwable. You can not use domain-specific errors without wrapping them in exception classes.

- It doesn’t help with distinguishing between expected failures and unexpected defects.

Although Try is a good starting point, for anything more than simple wrapping exception-throwing code, you’ll need something more expressive.

Either: Making Error Types Explicit

Either[E, A] is the more general approach. It represents a value that is either a Left(error) or a Right(success)Either is right-biased, meaning that map and flatMap operate on the Right side. If you encounter older Scala code, you’ll see a very different pattern when working with Either. Before Scala 2.12, you had to use .right projections explicitly, which made for-comprehensions cumbersome.

The biggest power of Either lies in typed, domain-specific errors:

sealed trait ValidationError
case class ParsingError(s: String) extends ValidationError
case class NegativeNumber(n: Int) extends ValidationError

def parseInt(s: String): Either[ValidationError, Int] =
  s.toIntOption.toRight(ParsingError(s))

def positive(n: Int): Either[ValidationError, Int] =
  if (n > 0) Right(n) else Left(NegativeNumber(n))

val result = for {
  n <- parseInt("6")
  p <- positive(n)
} yield p
// result: Right(6)

val failedResult = for {
  n <- parseInt("abc")
  p <- positive(n)
} yield p
// result: Left(ParsingError("abc"))

Here, the error type is visible in the function signature. The caller knows exactly what can go wrong, and the compiler ensures that every case is handled correctly. Using a sealed trait enables exhaustive checks during pattern matching.

Either works well for pure validations, parsing, and business rules, but it has its own limitations:

- It doesn’t manage side effects on its own.

- Composing across different error types gets verbose. Usually, you need to map errors into a common type manually.

- There is no built-in retry, timeout, or resource management. You have to handle all of that yourself.

For pure logic, Either is an excellent solution, but for applications that talk to the outside world, you need something more.

ZIO: Typed Errors That Scale With Your Application

ZIO takes the typed error channels idea from Either and builds an entire effect system around it. The core type is ZIO[R, E, A], where R stands for environment (dependencies), E is the error type, and A is the success type.

The basic usage of ZIO:

import zio._

sealed trait AppError
case class ParsingError(s: String) extends AppError
case class NegativeNumber(n: Int) extends AppError

def parseInt(s: String): IO[AppError, Int] =
  ZIO.fromOption(s.toIntOption).mapError(_ => ParsingError(s))

def positive(n: Int): IO[AppError, Int] =
  if (n > 0) ZIO.succeed(n) else ZIO.fail(NegativeNumber(n))

val result: IO[AppError, Int] = for {
  n <- parseInt("6")
  p <- positive(n)
} yield p

So far, everything looks like Either. The difference shows up when your application does some real work.

Retries and Resilience

As you know, external services fail, networks drop, and databases time out. ZIO provides retry logic as a first-class concept:

def fetchData(url: String): IO[AppError, String] = ???

val resilient = fetchData("some-url")
  .retry(Schedule.exponential(1.second) && Schedule.recurs(3))

With Either, you would typically need to write a recursive loop or pull in another library, but here you can see that just a few lines of code are enough to express exponential backoff with a bounded number of retries.

Failures and Defects

ZIO draws a clear line between typed failures and defects:

- Typed failures (ZIO.fail) represent expected, recoverable problems (invalid input or business rule violation). These appear in the E type parameter.

- Defects (ZIO.die) represent bugs (exceptions or logic errors that should never happen). These are untyped and don’t show up in the signature.

This distinction is really important. IO[ValidationError, User] tells you explicitly that the function can fail with a ValidationError, and that anything else is a defect that should crash loudly rather than be silently caught.

Recovery Operators

ZIO gives you smooth control over error recovery:

val handled = parseInt("abc")
  .catchAll(err => ZIO.succeed(-1)) // recover from all typed errors

val partial = parseInt("abc")
  .catchSome { case ParseError(_) => ZIO.succeed(0) } // recover from specific errors

val fallback = parseInt("abc")
  .orElse(parseInt("0")) // try an alternative

These operators allow you to transform complex error-handling logic into a declarative flow without nested try-catch blocks.

Choosing the Right One

These three approaches aren’t competing alternatives. In practice, many Scala applications use all three. For example:

- Use Try for wrapping Java or legacy code that throws exceptions. It’s the quickest way to turn exceptions into values.

- Use Either for writing functions with domain-specific errors. It’s great for validations, parsing, and business logic where side effects aren’t involved.

- Use ZIO when your application involves I/O, concurrency, or external services, and you need typed errors combined with retries, timeouts, resource safety, or dependency management.

Error handling isn’t a problem you can solve once and then forget about it. It’s a design choice that shapes how your application responds to failure. Moving from try/catch to Try, from Try to Either, and from Either to ZIO, isn’t merely about adopting new libraries; it’s a shift in how you model the unexpected. The more explicit you define your errors, the fewer surprises you get when it really matters.

References

- Chiusano, P. and Bjarnason, R. (2015). Functional programming in Scala. https://www.manning.com/books/functional-programming-in-scala

- ZIO Contributors. (n.d.). Introduction to error management in ZIO. https://zio.dev/reference/error-management

- ZIO Contributors. (n.d.). Handling Errors. https://zio.dev/overview/handling-errors/

Share on:
You might also like reading these blog posts:
Stop Chasing Ideas Start Validating Products

Filip Komnenović

Learn how to validate product ideas before building. Discover practical product discovery methods, real-world examples, and proven frameworks to reduce risk and build products people want.
Company Culture: What It Is and How to Grow It

Milana Tucakov

Company culture is the personality of your workplace the mix of values, beliefs, and everyday behaviours that shape how people work together.