Choosing Error Handling in Scala: Exceptions, Try, Either, or Effect Systems
A decision guide for Scala error handling: compare exceptions, Try, Either, Validated, cats-effect IO, and ZIO with a concrete Either implementation.
01 Oct 2026, 08:49 UTC

The Decision and Constraints
Every Scala service needs a consistent way to represent recoverable failures. The choice affects type signatures, composability, interop with Java libraries, and the learning curve for the team. Use these constraints to decide:
- Errors must be visible in method signatures so callers cannot ignore them.
- Error handling should compose naturally in
for-comprehensions. - Java APIs that throw exceptions must be integrated cleanly at boundaries.
- The happy path should have minimal overhead.
- The approach must be learnable by the existing team within a sprint.
This guide compares the mainstream options, explains trade-offs, and provides a concrete Either-based implementation with a validation plan.
Comparison of Options
| Approach | Error Visibility | Composition | Error Type | Interop with Throwing APIs | Learning Curve |
|---|---|---|---|---|---|
| Exceptions | Invisible in signatures | Try/catch blocks | Throwable | Native | Low |
Try[A] | Visible as Try | Monadic, eager | Throwable | Try(throwingCall) | Low |
Either[E, A] | Visible as Either | Right-biased monad | Domain type E | Try(...).toEither | Moderate |
Validated[E, A] | Visible as Validated | Applicative (accumulates) | Domain type E | Same as Either | Moderate |
cats-effect IO[A] | Visible as IO | Monadic, async | Throwable | IO.blocking(...) | High |
ZIO ZIO[R, E, A] | Visible as ZIO | Monadic, async | Domain type E | ZIO.attempt(...) | High |
Trade-offs
Exceptions: Reserve for Truly Exceptional Conditions
Use exceptions only for bugs, broken invariants, or out-of-memory errors. They are invisible in signatures, meaning callers cannot be forced to handle them. Catch them at the outermost boundary (for example, the main entry point or HTTP server layer) and convert them once to a domain error. Avoid using exceptions for routine control flow.
Try: A Boundary Adapter, Not a Public Error Type
Try wraps a Throwable eagerly. It is useful at the exact line where you call a throwing Java API: Try(parser(raw)).toEither.left.map(toDomainError). Exposing Try in public APIs leaks Throwable and loses the domain meaning of failures.
Either: Default Choice for Domain Errors
Either[E, A] provides a typed error E in the signature. Since Scala 2.12, it is right-biased, meaning map and flatMap work on the Right side, and for-comprehensions short-circuit on the first Left. A sealed-trait error hierarchy enables exhaustive pattern matching. The cost is the effort required to design the error type and adapt throwing APIs at boundaries.
Validated: Accumulate Errors, Then Convert
Validated is used to accumulate multiple errors (for example, form validation) but lacks flatMap. It cannot be used directly in a for-comprehension. Convert it to Either using .toEither after the accumulation phase is complete.
Effect Systems: When Async and Resources Dominate
Adopt cats-effect IO or ZIO if the team accepts the dependency and learning curve. cats-effect IO uses a Throwable-typed error channel via raiseError; typed errors require EitherT or a wrapper. ZIO provides a native typed error parameter E. Either composes well inside either system, allowing you to keep domain errors as Either and lift them at the boundary.
Concrete Implementation: Either-Based Domain Errors
The following implementation assumes Scala 2.13 or 3.x. It demonstrates a sealed error hierarchy, a type alias for brevity, and a composed service method.
import scala.util.Try
final case class User(id: String, name: String)
// Domain error hierarchy — keep sealed for exhaustivity checking
sealed trait AppError extends Product with Serializable
final case class NotFound(id: String) extends AppError
final case class BadInput(msg: String) extends AppError
final case class UpstreamFailure(cause: String) extends AppError
// Public result type used in signatures
type Result[A] = Either[AppError, A]
// Boundary adapter: convert throwing parser to typed error
def parseUserId(raw: String): Result[String] =
Try(java.util.UUID.fromString(raw))
.toEither
.left.map(_ => BadInput(s"Invalid UUID: $raw"))
// Service call that returns Result
def loadUser(id: String): Result[User] =
if (id == "exists") Right(User(id, "Scala Dev"))
else Left(NotFound(id))
// Composed operation: short-circuits on first Left
def getUser(rawId: String): Result[User] =
for {
id <- parseUserId(rawId)
user <- loadUser(id)
} yield user
AppErroris sealed; adding a new subtype without updating handlers will trigger a compile warning.parseUserIdusesTry(...).toEitherto bridge the gap between throwing Java code and typed Scala errors.- The
for-comprehension ingetUserstops at the firstLeft;loadUseris never called if parsing fails.
Validation Plan
Run these checks to confirm the behavior matches the design.
1. Compile on the Target Scala Version
Run sbt compile on the production Scala version (2.13.x or 3.x). Verify no implicit conversions are required for the for-comprehension over Either.
2. Unit Test: Short-Circuit Behavior
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class UserServiceSpec extends AnyFunSuite with Matchers {
test("getUser short-circuits on parse failure") {
var loadCalled = false
def loadUser(id: String): Result[User] = {
loadCalled = true
Right(User(id, "name"))
}
def parseUserId(raw: String): Result[String] =
Left(BadInput("boom"))
val result = for {
id <- parseUserId("bad")
user <- loadUser(id)
} yield user
result shouldBe Left(BadInput("boom"))
loadCalled shouldBe false // second step never ran
}
}
3. Exhaustivity Checking
Enable strict match exhaustivity in your build. For Scala 2.13, add -Werror -Wconf:cat=match-exhaustive:e to scalacOptions; for Scala 3, use -Wall. Then, add a new error subtype (for example, case class RateLimited(retryAfter: Int) extends AppError) without updating your match blocks. The build should fail, confirming the sealed hierarchy is enforced.
Limitations and Practical Checks
- Scala Version: Right-biased
Eitherrequires 2.12+. For 2.11, use.right.map. - Effect Mixing: Placing
EitherinsideFutureorIOcreates two error paths. Pick one layer and convert at the boundary (for example,IO.fromEither) to avoid swallowed failures. - cats-effect:
IO.raiseErrortakes aThrowable. For typed errors, useEitherT[IO, AppError, A]. - Open Traits: If
AppErroris not sealed, exhaustivity checking is lost. Always usesealed traitfor domain errors.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.