Every program fails, and the question is how those failures appear in your code. Java-style Scala throws exceptions and hopes someone catches them. Functional Scala treats most failures as ordinary values: a function that can fail says so in its return type, the compiler makes callers deal with it, and composition with map and flatMap keeps the happy path readable.
Scala gives you several tools for this, and teams often mix them at random: Option in one module, Try in another, Either[String, A] in a third and raw exceptions inside IO. This article gives you a way to choose. It starts with a classification of failures, maps each class to a type, shows how to design error types and translate them between layers, and then covers the error channels of Cats Effect and ZIO. Either itself is covered in depth in Scala Either; here it is one tool among several.
Three kinds of failure
Start by asking who should react to a failure, because that decides how it should be represented.
- Absence. A lookup found nothing. It is not really an error, and the caller usually has a sensible default. Example: a user has no saved shipping address.
- Expected domain errors. The business rules say no: insufficient funds, an expired coupon, an invalid email. The caller must handle each case, often differently, and the cases belong in your domain model.
- Defects. Something that should not happen, or that no caller can fix: a null where none should be, a lost database connection, an out-of-memory error, a bug. The right reaction is usually to abort the current request, log, return a generic 500 and alert.
The core rule follows from this. Absence and domain errors become values in the return type, so the compiler forces callers to handle them. Defects stay out of the domain types and travel through the failure channel of your effect (or as exceptions), to be handled once at the edge of the program. Mixing the two is the most common design mistake: if every function returns Either[Throwable, A], callers cannot tell 'card declined' from 'bug in the parser', and so they handle neither properly.
Choosing the type
| Situation | Type | Why |
|---|---|---|
| Value may be missing, no reason needed | Option[A] | absence is not an error; getOrElse gives a default |
| Wrapping Java or legacy code that throws | Try[A] | catches non-fatal exceptions at the boundary; convert soon |
| One failure stops the computation | Either[E, A] | typed error, short-circuits on the first Left |
| Report every problem at once (forms, config) | Validated / ValidatedNec[E, A] | accumulates errors instead of stopping |
| Side effects that can fail | IO[A] / ZIO[R, E, A] | the effect's error channel carries failures |
Two notes on Try. It catches only non-fatal exceptions (it uses NonFatal, so OutOfMemoryError and similar still propagate), and its error type is always Throwable, which says nothing about which failures are possible. Use it to wrap a throwing API at the edge, then convert to Either with a meaningful error type. Also note that Try breaks the left-identity law when the function passed to flatMap throws; Monads in Scala explains why.
Option is the wrong tool for errors. If the caller needs to know why something failed, for example to show a message or decide whether to retry, None has thrown that information away.
Designing error types
A good error type is a closed set of cases, each carrying the data a handler needs. In Scala 3, an enum is the most direct way to write it; in Scala 2, use a sealed trait with case classes. The compiler then warns when a pattern match misses a case, which is the real payoff of typed errors.
enum TransferError:
case AccountNotFound(id: AccountId)
case InsufficientFunds(available: BigDecimal, requested: BigDecimal)
case AccountFrozen(id: AccountId)
case SameAccount
def toHttp(e: TransferError): (Int, String) = e match
case TransferError.AccountNotFound(id) => (404, s"account $id not found")
case TransferError.InsufficientFunds(a, r) => (422, s"needs $r, has $a")
case TransferError.AccountFrozen(id) => (423, s"account $id is frozen")
case TransferError.SameAccount => (400, "source and target are the same")Guidelines that keep error types useful:
- One error type per bounded context or service, not one global error type for the whole application. A global type grows into a list of every failure in the company, and every function appears able to return all of them.
- Do not use
Stringas an error type. It cannot be matched safely, cannot carry data and is impossible to translate reliably. - Carry data, not prose. Put the requested and available amounts in the case and format the message at the edge, where the language and audience are known.
- Do not extend
Exceptionunless the error must travel through an exception-based channel. If it must, mix inscala.util.control.NoStackTraceso that creating it does not capture an expensive stack trace for an expected condition.
Accumulating validation errors
Either stops at the first failure, which is wrong for user input: a form with three bad fields should report three errors, not one per submission. Cats' Validated is an applicative, not a monad, so it can run independent checks and combine their errors. ValidatedNec collects them in a NonEmptyChain.
import cats.data.ValidatedNec
import cats.syntax.all.*
enum FieldError:
case Blank(field: String)
case BadAmount(raw: String)
final case class TransferRequest(from: AccountId, to: AccountId, amount: BigDecimal)
def nonBlank(field: String, v: String): ValidatedNec[FieldError, String] =
if v.trim.nonEmpty then v.trim.validNec else FieldError.Blank(field).invalidNec
def amount(raw: String): ValidatedNec[FieldError, BigDecimal] =
scala.util.Try(BigDecimal(raw.trim)).toOption.filter(_ > 0) match
case Some(a) => a.validNec
case None => FieldError.BadAmount(raw).invalidNec
def parse(from: String, to: String, amt: String): ValidatedNec[FieldError, TransferRequest] =
(nonBlank("from", from).map(AccountId(_)),
nonBlank("to", to).map(AccountId(_)),
amount(amt)).mapN(TransferRequest.apply)Calling parse("", "", "-5") returns an Invalid holding all three errors. Once validation succeeds, convert with .toEither and continue with fail-fast logic, because later steps usually depend on earlier results and cannot run independently. A useful habit: validate the shape of input with Validated, then run business rules with Either. If you already hold Either[NonEmptyChain[E], A] values, Cats can accumulate them with parMapN instead of converting by hand; traverse and sequence covers doing the same over collections.
Trace one bad request end to end. A client posts from = '', to = '' and amount = '-5'. The three checks run independently, so parse returns an Invalid holding Blank(from), Blank(to) and BadAmount(-5) in that order. The HTTP layer maps each case to a field-level message and returns a single 400 response listing all three, and the transfer logic never runs. Now the client fixes the fields but names the same account twice. Validation passes, .toEither yields a Right, and the business rules take over: the first rule fails with SameAccount, the later rules are skipped, and the edge returns 400 with that one message. Two kinds of check, two error types, two behaviours, each chosen on purpose.
Translating errors at layer boundaries
Each layer should speak its own error language. A repository knows about SQL exceptions; the service knows about insufficient funds; the HTTP layer knows about status codes. Translate at every boundary, close to where the lower-level error appears, and never let a low-level type leak upward. A PSQLException in a controller's pattern match means the boundary was skipped.
Translation is mostly leftMap (Cats syntax on Either), mapError (ZIO) or adaptError (Cats Effect). It is also where you decide whether a lower-level failure is expected or a defect. A unique-constraint violation on insert may be the expected 'account already exists'; a dropped connection is a defect and should not be turned into a domain error.
Errors inside effects: Cats Effect
Cats Effect's IO has one error channel, fixed to Throwable. Through the MonadError / ApplicativeError type classes it provides raiseError, handleErrorWith, recover, attempt (which turns IO[A] into IO[Either[Throwable, A]]) and adaptError. That channel suits defects. For expected domain errors there are two common styles: return IO[Either[TransferError, A]] explicitly (or wrap it in EitherT), or make the domain error extend NoStackTrace, raise it in the channel and recover it at the edge. The first keeps errors visible in signatures; the second is terser but the compiler no longer tracks them.
import cats.data.EitherT
import cats.effect.IO
import cats.syntax.all.*
def transfer(req: TransferRequest): IO[Either[TransferError, Receipt]] =
(for
_ <- EitherT.cond[IO](req.from != req.to, (), TransferError.SameAccount)
from <- EitherT.fromOptionF(repo.find(req.from), TransferError.AccountNotFound(req.from))
to <- EitherT.fromOptionF(repo.find(req.to), TransferError.AccountNotFound(req.to))
_ <- EitherT.cond[IO](!from.frozen, (), TransferError.AccountFrozen(from.id))
_ <- EitherT.cond[IO](from.balance >= req.amount, (),
TransferError.InsufficientFunds(from.balance, req.amount))
r <- EitherT.right[TransferError](repo.move(from, to, req.amount)) // defects stay in IO
yield r).valueHere repo.find returns IO[Option[Account]] and any database failure inside it remains a raised Throwable: it is a defect and is not converted into TransferError. Resource safety is part of error handling too: acquire connections with Resource.make so release runs on success, error and cancellation. More on the runtime in Cats Effect.
Errors inside effects: ZIO
ZIO puts the error type in the effect itself: ZIO[R, E, A]. Expected errors go in E; defects are a separate category the runtime tracks as dying. This makes the split in the first section explicit in every signature.
import zio.*
def find(id: AccountId): IO[TransferError, Account] =
ZIO.attempt(jdbc.load(id)) // Task[Option[Account]]: may throw
.orDie // a broken DB is a defect, not a domain error
.someOrFail(TransferError.AccountNotFound(id))
def transfer(req: TransferRequest): IO[TransferError, Receipt] =
for
_ <- ZIO.fail(TransferError.SameAccount).when(req.from == req.to)
from <- find(req.from)
to <- find(req.to)
_ <- ZIO.fail(TransferError.InsufficientFunds(from.balance, req.amount))
.when(from.balance < req.amount)
r <- move(from, to, req.amount)
yield r
val handled: IO[TransferError, Outcome] = transfer(req).map(Outcome.Approved(_)).catchAll {
case TransferError.InsufficientFunds(a, r) => ZIO.succeed(Outcome.Declined(a, r))
case other => ZIO.fail(other)
}orDie moves a failure from the typed channel to the defect channel, refineOrDie keeps the exceptions you list and treats the rest as defects, mapError translates, and catchAll handles every typed error (the compiler checks the error type of the result). See Getting started with ZIO for the runtime.
Failure modes and anti-patterns
- Swallowing errors.
getOrElseorrecover { case _ => default }deep inside a library hides failures from everyone above. Recover only where you know what the right answer is. - Catching everything.
catch { case e: Throwable => }also catches fatal errors and interruptions. UseNonFatalor let the effect system handle it. - Defects as domain errors. Turning a lost connection into a
Leftinvites callers to 'handle' it with a business response, so a database outage is shown as 'account not found'. - Throwing inside map. A throw inside
Either.mapis not captured by the Either; it escapes as an exception. InsideIOit becomes a raised error, which may be what you want, but it bypasses typed errors. - Logging and rethrowing at every layer. The same failure appears five times in the logs. Log once, at the edge, with context added as the error travelled up.
- Stack traces for expected errors. Exceptions used for control flow are expensive to construct; use values or
NoStackTrace.
Trade-offs
Typed errors make signatures longer and composition sometimes awkward: combining Option, Either and IO in one for-comprehension needs transformers such as EitherT, which add allocation and noise, or an effect type with a built-in error channel. Validated cannot be used in a for-comprehension because it is not a monad, which surprises newcomers. Exceptions are cheaper to write and fine for defects, which is exactly where you should keep them. The practical balance is typed values for anything a caller should react to, the effect's failure channel for everything else, and one translation per boundary.
What to do next
- List the failures of one service and sort each into absence, expected error or defect.
- Replace any
Either[String, A]orEither[Throwable, A]in that service with a sealed error type or Scala 3 enum. - Move input validation to
ValidatedNecso users see every problem at once. - Find the boundaries where a lower-level error type leaks upward and add a translation with
leftMap,mapErrororadaptError. - Add one top-level handler that logs defects with context, returns a generic 500 and maps each domain error with an exhaustive match.
- Grep for
case _ =>inside recovery blocks and justify or remove each one.