Option, Either and functional error handling

Replace null with Option, carry failure reasons in Either, and use for-comprehensions to chain computations that may fail.

Option instead of null

val config: Map[String, String] = Map("host" -> "db", "port" -> "5432")

val host: Option[String] = config.get("host")

// the transformations you use constantly
val upper: Option[String] = host.map(_.toUpperCase)
val port: Option[Int]     = config.get("port").flatMap(_.toIntOption)
val all: Option[(String, Int)] = host.zip(port)

// getOrElse needs an eager default; orElse takes an alternative Option
val shown = host.getOrElse("localhost")
val fallback = host.orElse(config.get("db.host")).getOrElse("localhost")

// a default that is expensive and should only be computed when needed
lazy val computed = host.getOrElse { Thread.sleep(100); "slow-default" }

// fold: handle both branches and produce one type
val message = port.fold("no port configured")(p => s"listening on $p")

// pattern matching, which the compiler checks for totality
host match
  case Some(h) if h.nonEmpty => println(s"host is $h")
  case Some(_)               => println("empty host")
  case None                  => println("missing")

// never do this: it throws and defeats the whole point
// host.get
  • Option is a sealed hierarchy of Some and None, so a match is exhaustive and the compiler enforces it.
  • collect combines a filter and a map: port.collect { case p if p > 0 => p }.
  • Option is not a collection you should treat as one in a hot path, but it does have foreach, filter and exists.
  • Never return null from a Scala API. If you interop with Java that does, wrap the result in Option(...) at the boundary and it becomes a compile-time concern from then on.

Either for failure with a reason

enum ValidationError:
  case Empty(field: String)
  case TooLong(field: String, max: Int)
  case NotANumber(field: String, value: String)

import ValidationError.*

def validateName(raw: String): Either[ValidationError, String] =
  val trimmed = raw.trim
  if trimmed.isEmpty then Left(Empty("name"))
  else if trimmed.length > 32 then Left(TooLong("name", 32))
  else Right(trimmed)

def validateAge(raw: String): Either[ValidationError, Int] =
  raw.toIntOption match
    case Some(n) if n >= 0 && n <= 150 => Right(n)
    case Some(_)                       => Left(NotANumber("age", raw))
    case None                          => Left(NotANumber("age", raw))

// for-comprehension: reads like a script but is a chain of flatMaps
def parseUser(name: String, age: String): Either[ValidationError, (String, Int)] =
  for
    n <- validateName(name)
    a <- validateAge(age)
  yield (n, a)

// fail-fast: the first Left short-circuits the whole comprehension
// validateName("") -> Left(Empty("name")), validateAge is never called

parseUser("  ada ", "36") match
  case Right((n, a)) => println(s"$n is $a")
  case Left(Empty(f)) => println(s"$f is required")
  case Left(e)        => println(s"invalid: $e")
TypeSuccessFailureUse when
Option[A]Some(a)NoneAbsence is the only failure mode
Either[E, A]Right(a)Left(e)You need a reason, and it is not an exception
Try[A]Success(a)Failure(t)Wrapping Java code that throws
Validated[E, A]Valid(a)Invalid(errors)Collect every error, not just the first
AThe valueAn exceptionGenuinely exceptional, unrecoverable situations
import scala.util.{Try, Success, Failure}

// wrap a throwing call once, at the boundary
val parsed: Try[Int] = Try("not a number".toInt)

parsed match
  case Success(v) => println(v)
  case Failure(e) => println(s"failed: ${e.getMessage}")

// Either and Try agree on for-comprehensions, so they compose
def readPort(s: String): Either[String, Int] =
  Try(s.toInt).toEither.left.map(_ => s"'$s' is not a number").filterOrElse(_ > 0, "port must be positive")

Keeping exceptions out of the domain

Exceptions are for the edges: I/O, a broken invariant, a bug. Model the outcomes you expect your caller to handle as values, and both the caller and the compiler can see them.

  • Parse, do not validate in the middle of a computation. Turn untrusted input into a domain type once, at the boundary, and the rest of the code cannot receive something invalid.
  • A domain type with a private constructor plus a smart constructor in the companion object makes an invalid instance impossible to build.
  • Use Either when the first error is enough, and a accumulating type when the user needs every problem at once. The two have genuinely different call sites.
  • getOrElse on an Either requires a function from the error, so you cannot silently discard the reason.
// a smart constructor: the only way to build an Email is through parse
final case class Email private (value: String)

object Email:
  private val Pattern = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$".r

  def parse(raw: String): Either[String, Email] =
    raw.trim match
      case Pattern() => Right(new Email(raw.trim.toLowerCase))
      case other     => Left(s"'$other' is not a valid email")

// downstream code takes Email, so it can never receive a malformed string
def send(to: Email, body: String): Either[String, Unit] = Right(())
⚠️
Do not catch Throwable to make a computation total. Catching InterruptedException and restoring nothing silently breaks cancellation, and catching an OutOfMemoryError leaves the JVM in a state you cannot recover from. Catch specific exception types, and only where you can actually respond.

FAQ

Should a method return Option or Either?
Option when the caller only needs to know that there is nothing. Either when the caller might do something different depending on why it failed, or needs to report the reason to a user.
Is a for-comprehension always a loop?
No. It desugars to flatMap, map and withFilter calls on whatever monad you use. Over Either it short-circuits on the first Left; over Future it sequences asynchronous work.

Case classes and pattern matching Collections and functional style

Last refreshed 2026-09-18.