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.getOptionis a sealed hierarchy ofSomeandNone, so a match is exhaustive and the compiler enforces it.collectcombines a filter and a map:port.collect { case p if p > 0 => p }.Optionis not a collection you should treat as one in a hot path, but it does haveforeach,filterandexists.- Never return
nullfrom a Scala API. If you interop with Java that does, wrap the result inOption(...)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")| Type | Success | Failure | Use when |
|---|---|---|---|
Option[A] | Some(a) | None | Absence 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 |
A | The value | An exception | Genuinely 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
Eitherwhen the first error is enough, and a accumulating type when the user needs every problem at once. The two have genuinely different call sites. getOrElseon anEitherrequires 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.Related
Case classes and pattern matching Collections and functional style
Last refreshed 2026-09-18.