Every non-trivial function can fail. Scala gives you several ways to say so: throw an exception, return null, return an Option, or return an Either. Either[E, A] is the one that puts the reason for failure in the type signature. A value is either a Left(e) carrying an error of type E, or a Right(a) carrying a result of type A. The compiler then forces every caller to deal with both cases, or pass them on explicitly.

This article explains Either from first principles and builds up to the patterns used in production Scala. It covers what right-bias means and why it changed in 2.12, the combinators worth knowing, how a for-comprehension short-circuits on the first failure, how to design error types, how to collect all errors instead of the first, and how to combine Either with effects such as cats-effect IO. Code targets Scala 2.13 and Scala 3, plus cats 2.x where noted. Either is a monad, so the monads article is useful background, but this page assumes no prior knowledge.

Advertisement

Why not exceptions or Option?

An exception is invisible in the signature. def parseQty(s: String): Int says nothing about what happens with "abc". Callers learn about the NumberFormatException from a production stack trace. Exceptions also jump over intermediate code, so it is hard to see which failures a block can produce, and building a stack trace costs time when failure is an expected outcome, such as bad user input.

Option[A] fixes the visibility but discards the reason. None cannot tell a missing field from a malformed one or an unknown product, and the HTTP layer needs exactly that distinction to choose between 400, 404 and 409. Either keeps both properties. Failure is visible in the type, like Option, and the failure carries data, like an exception, but as an ordinary value you can transform, test and pattern-match. Use exceptions for bugs and broken infrastructure, and Either for failures that are part of the domain.

The type and right-bias

Right-biased Either: map and flatMap run on the Right track; the first Left skips everything after itraw inputMap[String,String]parseQtyRight(3)lookupSkuRight(Sku)priceRight(Order)flatMapflatMapmapLeft(BadQty)Left(UnknownSku)Left(err)passed throughfailsfailsskippedskippedfold(handleError, handleSuccess) at the edge turns either track into an HTTP response, a log line or an exit codefold at the boundary400 / 404 / 200
Railway view of a right-biased Either pipeline. Each step can switch to the Left track; once there, later steps are skipped and the error is carried to the end, where fold handles both tracks.

In the standard library, Either is a sealed abstract class with two final case classes, Left[+A, +B] and Right[+A, +B]. Both type parameters are covariant. Nothing in the type itself says which side means success. Before Scala 2.12, Either was unbiased: you had to write either.right.map(...) to say which side to transform, and you could not use it directly in a for-comprehension.

Scala 2.12 made Either right-biased. map, flatMap, foreach and friends now operate on the Right value and pass a Left through untouched. By convention Right means success (it is the "right" answer), and Left carries the error. Right-bias is what makes Either a monad in practice: flatMap chains steps that may fail, and the first Left wins.

// Scala 2.13 / Scala 3
val ok: Either[String, Int]  = Right(42)
val bad: Either[String, Int] = Left("not a number")

ok.map(_ + 1)                          // Right(43)
bad.map(_ + 1)                         // Left("not a number"), function never runs
ok.flatMap(n => if (n > 0) Right(n) else Left("must be positive"))

ok.fold(err => s"error: $err", n => s"value: $n")   // collapse both sides
bad.left.map(_.toUpperCase)            // Left("NOT A NUMBER"): transform the error
bad.swap                               // Right("not a number"): Either[Int, String]
ok.getOrElse(0)                        // 42
bad.orElse(Right(0))                   // Right(0): fallback computation
ok.filterOrElse(_ % 2 == 0, "odd")     // Right(42)
ok.toOption                            // Some(42); bad.toOption == None
ok.contains(42); ok.exists(_ > 40); bad.forall(_ > 40)   // true, true, true

left.map uses a left projection, which is the supported way to transform the error side. swap is the other: swap, map, then swap back. In Scala 2.13, .left.get and .right.get on projections are deprecated, because they throw. Prefer fold, pattern matching or getOrElse.

Advertisement

Building Eithers from the world

Most Eithers come from converting something else. The standard library gives you the main bridges:

import scala.util.Try

Either.cond(age >= 18, Adult(age), "too young")         // Right if true, Left if false
sys.env.get("PORT").toRight("PORT not set")             // Option -> Either
Try(Integer.parseInt(s)).toEither                       // Either[Throwable, Int]
Try(Integer.parseInt(s)).toEither.left.map(_ => BadQty(s))  // domain error instead

// toTry needs the Left to be a Throwable
val t: Try[Int] = Try(Integer.parseInt(s)).toEither.toTry

Be careful with Try(...).toEither. It produces Either[Throwable, A], and Throwable is a poor error type: it tells the caller nothing about which failures are possible. Convert at the boundary with .left.map to a domain error, as shown. Also note that Try only catches non-fatal exceptions, so OutOfMemoryError still propagates, which is what you want.

Worked example: validating an order request

Here is a realistic flow. An HTTP handler receives a form as Map[String, String] and must produce an order or a precise error. Each step returns an Either, and a for-comprehension chains them.

sealed trait OrderError
final case class Missing(field: String)        extends OrderError
final case class BadQty(raw: String)           extends OrderError
final case class UnknownSku(sku: String)       extends OrderError
final case class OutOfStock(sku: String, left: Int) extends OrderError

final case class Order(sku: Sku, qty: Int, totalCents: Long)

def field(req: Map[String, String], name: String): Either[OrderError, String] =
  req.get(name).toRight(Missing(name))

def parseQty(raw: String): Either[OrderError, Int] =
  raw.toIntOption.filter(_ > 0).toRight(BadQty(raw))

def lookupSku(id: String): Either[OrderError, Sku] =
  catalog.get(id).toRight(UnknownSku(id))

def reserve(sku: Sku, qty: Int): Either[OrderError, Sku] =
  Either.cond(sku.stock >= qty, sku, OutOfStock(sku.id, sku.stock))

def placeOrder(req: Map[String, String]): Either[OrderError, Order] =
  for {
    rawQty <- field(req, "qty")
    qty    <- parseQty(rawQty)
    skuId  <- field(req, "sku")
    sku    <- lookupSku(skuId)
    _      <- reserve(sku, qty)
  } yield Order(sku, qty, sku.priceCents * qty)

placeOrder(request).fold(
  {
    case Missing(f)       => BadRequest(s"missing $f")
    case BadQty(r)        => BadRequest(s"bad quantity '$r'")
    case UnknownSku(s)    => NotFound(s"no sku $s")
    case OutOfStock(s, n) => Conflict(s"only $n of $s left")
  },
  order => Created(order)
)

Trace two requests. With qty=3, sku=A1 and enough stock, every step returns Right and the yield builds the Order. With qty=0, parseQty returns Left(BadQty("0")); the for-comprehension desugars to nested flatMap calls, so field(req, "sku") and everything after it never run. The handler folds the Left into a 400. Because OrderError is sealed, the compiler warns if a new error case is added and the fold does not handle it, which is the main reason to model errors as an algebraic data type. See sealed traits and ADTs for the technique in general.

Notice that reserve returns a value the for-comprehension discards with _ <-. That is the idiom for a validation step that either passes or fails.

For-comprehension pitfalls

A for-comprehension over Either desugars to flatMap and map. Two features need a withFilter method, which Either does not have, because a filter must produce an empty value and Either has no way to invent an error for you. The first is guards (if clauses). The second, in Scala 2, is pattern bindings such as tuple destructuring in a generator.

// Scala 2.13: does NOT compile -- "value withFilter is not a member of Either"
for {
  (a, b) <- pairE          // tuple pattern needs withFilter in Scala 2
  if a > 0                 // guards always need withFilter
} yield a + b

// Works in both: bind, then destructure
for {
  pair <- pairE
  (a, b) = pair
  r    <- Either.cond(a > 0, a + b, "a must be positive")
} yield r

Scala 3 treats a pattern whose type matches the scrutinee as irrefutable, so the tuple generator compiles there, but guards still do not. In both versions, replace guards with filterOrElse or Either.cond, which force you to say which error a failed check produces. That is better design anyway.

Designing the error type

The choice of E matters more than any combinator. A String is fine in a script, but callers cannot branch on it reliably. Throwable is too open. A sealed trait per bounded context, with case classes that carry the data needed to explain the failure, gives exhaustiveness checking and structured logs.

Two type-inference traps appear quickly. First, Left(BadQty("x")) on its own is inferred as Left[BadQty, Nothing], so a val or an if branch can end up with a narrower type than you want. Annotate the return type of every function that returns Either, or use cats' .asLeft[Order] and .asRight[OrderError] syntax. Second, when composing functions from different modules, such as Either[DbError, A] and Either[PaymentError, B], the for-comprehension infers their least upper bound, often Product with Serializable in Scala 2. Map each error into a shared supertype at the module boundary with .left.map(AppError.Db(_)) so the union is explicit. Scala 3 union types (Either[DbError | PaymentError, A]) are another option for small, local combinations.

Fail fast versus accumulate

Monadic Either stops at the first error, which is right when later steps depend on earlier ones: you cannot look up a SKU you failed to parse. For independent checks, such as validating every field of a signup form, users want all the errors at once. That needs applicative composition rather than monadic composition, a distinction explained in functors and applicatives. The cats library provides it.

import cats.data.{EitherNec, ValidatedNec}
import cats.syntax.all._

final case class Signup(email: String, age: Int, name: String)

def email(s: String): EitherNec[String, String] =
  if (s.contains("@")) s.rightNec else "email: missing @".leftNec
def age(n: Int): EitherNec[String, Int] =
  if (n >= 18) n.rightNec else s"age: $n is under 18".leftNec
def name(s: String): EitherNec[String, String] =
  if (s.trim.nonEmpty) s.rightNec else "name: empty".leftNec

// Fail fast: stops at the first Left
val first = (email("x"), age(12), name("")).mapN(Signup.apply)
// Left(Chain(email: missing @))

// Accumulate: same functions, parallel semantics
val all = (email("x"), age(12), name("")).parMapN(Signup)
// Left(Chain(email: missing @, age: 12 is under 18, name: empty))

// Or explicitly with Validated
val v: ValidatedNec[String, Signup] =
  (email("x").toValidated, age(12).toValidated, name("").toValidated).mapN(Signup.apply)

EitherNec[E, A] is Either[NonEmptyChain[E], A]: the error side is a non-empty sequence that appends cheaply. mapN on Eithers is still fail-fast, because it is consistent with flatMap. parMapN uses the Parallel instance, which runs the same Eithers through Validated and combines the errors with their Semigroup. A good rule: keep Either as your return type, and use parMapN or Validated only where independent checks are combined.

Collections of Eithers

Validating a list of inputs produces a List[Either[E, A]], which is usually the wrong shape. You want either Either[E, List[A]] (all succeeded, or the first error) or both lists split apart.

val raw = List("1", "2", "x", "4")

// stdlib, Scala 2.13+: split successes and failures
val (errors, nums) = raw.map(s => s.toIntOption.toRight(s)).partitionMap(identity)
// errors = List("x"), nums = List(1, 2, 4)

// all-or-nothing with cats: first Left wins
import cats.syntax.all._
raw.traverse(s => s.toIntOption.toRight(s"bad: $s"))   // Left("bad: x")
List("1", "2").traverse(_.toIntOption.toRight("bad"))   // Right(List(1, 2))

partitionMap is in the 2.13 standard library and suits batch jobs that process good rows and report bad ones. traverse from cats turns List[A] plus A => Either[E, B] into Either[E, List[B]]. Use parTraverse with an EitherNec to accumulate every bad row instead. Traverse and sequence covers these in depth.

Either inside effects: EitherT and the alternatives

Real code fails inside effects: a database call returns IO[Either[NotFound, User]]. Two layers of wrapping make for-comprehensions awkward, because each step needs a nested match. The monad transformer EitherT[F, E, A] wraps F[Either[E, A]] and gives you a single flatMap across both layers.

import cats.data.EitherT
import cats.effect.IO

def findUser(id: UserId): IO[Either[AppError, User]]       = ???
def chargeCard(u: User, cents: Long): IO[Either[AppError, Receipt]] = ???

// Without EitherT: nested pattern matching on IO[Either[...]]
// With EitherT: one for-comprehension over both effects
val program: EitherT[IO, AppError, Receipt] = for {
  user    <- EitherT(findUser(id))
  _       <- EitherT.cond[IO](user.active, (), AppError.Inactive(id))
  receipt <- EitherT(chargeCard(user, 4200))
} yield receipt

val result: IO[Either[AppError, Receipt]] = program.value

EitherT is not free. Each step allocates wrappers, type inference sometimes needs help (EitherT.cond[IO] above), and stacking several transformers gets unwieldy. Many cats-effect codebases instead use typed domain errors for expected outcomes at the edges, and IO.raiseError with an exception hierarchy for failures that should abort the request, converting with .attempt where a caller needs an Either again. ZIO builds the typed error channel into ZIO[R, E, A]. Pick one convention per codebase and write it down. The cats-effect runtime article covers how IO handles errors and cancellation.

Trade-offs and performance

Either's costs are small and predictable: one allocation per Right or Left, and a virtual call per combinator. Constructing a Left is far cheaper than throwing an exception, because no stack trace is captured, so Either is the better choice on hot paths where failure is common, such as parsing untrusted input. The costs that matter are ergonomic. Signatures get longer, error types must be designed and mapped at boundaries, and mixing Either with effects needs a convention. Do not use Either for bugs: an index out of range in your own code should still throw and be fixed, not become a Left that every caller must handle.

What to do next

  1. Find one service method that throws for an expected failure, such as not found or invalid input, and change it to return Either[DomainError, A] with a sealed error ADT.
  2. Annotate the return type of every Either-returning method, so Left[X, Nothing] inference never leaks.
  3. Replace if guards and .left.get / .right.get with filterOrElse, Either.cond and fold.
  4. Convert Try(...).toEither results to domain errors with .left.map at the boundary.
  5. For form or config validation, switch to parMapN over EitherNec and check that a request with three bad fields returns three messages.
  6. Agree, and document, one convention for errors inside effects: EitherT, typed errors in ZIO, or raiseError plus attempt.
  7. Handle the Left exactly once, with a fold or exhaustive match at the HTTP, CLI or message boundary.
Key takeaway: Either[E, A] makes failure part of the type and keeps the reason as data. Since Scala 2.12 it is right-biased, so map and flatMap chain successes and the first Left short-circuits a for-comprehension. Model E as a sealed ADT, annotate return types, avoid guards (use filterOrElse or Either.cond), and fold once at the boundary. Use parMapN or Validated when independent checks should report every error, traverse or partitionMap for collections, and EitherT or a documented effect-error convention when Either meets IO.