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.

Advertisement

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.

Where each kind of failure lives, and where it is translatedHTTP / CLI edgestatus codes, messagesService layerEither[DomainError, A]Repository / clientF[A], raises Throwableto responserefineInput validationValidatedNec[FieldError, A]toEitherDefectsbugs, OOM, lost DBTop-level handlerlog, 500, alertdefects bypass the domainExpected errors are values in the type; defects travel the effect's failure channeland are handled once, at the edge. Translation happens at each boundary, never deep inside.
Expected errors are values that flow up through the service layer and are translated at each boundary. Defects bypass the domain types and are handled once by a top-level handler.

Choosing the type

SituationTypeWhy
Value may be missing, no reason neededOption[A]absence is not an error; getOrElse gives a default
Wrapping Java or legacy code that throwsTry[A]catches non-fatal exceptions at the boundary; convert soon
One failure stops the computationEither[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 failIO[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.

Advertisement

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 String as 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 Exception unless the error must travel through an exception-based channel. If it must, mix in scala.util.control.NoStackTrace so 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).value

Here 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. getOrElse or recover { 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. Use NonFatal or let the effect system handle it.
  • Defects as domain errors. Turning a lost connection into a Left invites 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.map is not captured by the Either; it escapes as an exception. Inside IO it 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

  1. List the failures of one service and sort each into absence, expected error or defect.
  2. Replace any Either[String, A] or Either[Throwable, A] in that service with a sealed error type or Scala 3 enum.
  3. Move input validation to ValidatedNec so users see every problem at once.
  4. Find the boundaries where a lower-level error type leaks upward and add a translation with leftMap, mapError or adaptError.
  5. Add one top-level handler that logs defects with context, returns a generic 500 and maps each domain error with an exhaustive match.
  6. Grep for case _ => inside recovery blocks and justify or remove each one.
Key takeaway: Functional error handling in Scala starts with a classification, not a type: absence becomes Option, expected domain failures become a sealed error type carried in Either or in a typed effect channel, input validation that should report everything uses Validated, and defects travel the effect's failure channel to a single handler at the edge. Design error types as closed sets that carry data, translate them at every layer boundary, and never let a database exception or a String become part of your domain API. Cats Effect gives you MonadError over Throwable plus EitherT for typed errors; ZIO puts the error type in the signature and separates failures from defects for you.