Option, Either, and Try
Absence and failure as values rather than exceptions — chaining with map and flatMap, keeping the reason with Either, and why .get undoes all of it.
Scala runs on the JVM, so null exists — and idiomatic Scala never produces it. Absence is
Option, failure with a reason is Either, and a thrown exception captured as a value is
Try.
Option
val customers = Map(1 -> "Ada Lovelace", 2 -> "Grace Hopper", 3 -> "Alan Turing")
@main def run(): Unit =
val found = customers.get(1)
val missing = customers.get(99)
println(found)
println(missing)
println(found.getOrElse("unknown"))
println(missing.getOrElse("unknown"))
println(found.map(_.toUpperCase))
println(missing.map(_.toUpperCase))
println(found.isDefined, missing.isEmpty)
Some(Ada Lovelace)
None
Ada Lovelace
unknown
Some(ADA LOVELACE)
None
(true,true)
The key line is missing.map(_.toUpperCase) returning None rather than throwing. map on
an empty Option does nothing, so a chain of transformations is safe without a single null
check.
Handle both cases explicitly with fold or a match:
@main def run(): Unit =
val label = customers.get(99).fold("no such customer")(name => s"found $name")
println(label)
customers.get(2) match
case Some(name) => println(s"hello, $name")
case None => println("not found")
no such customer
hello, Grace Hopper
The method that undoes it
@main def run(): Unit =
println(customers.get(99).get)
Exception in thread "main" java.util.NoSuchElementException: None.get
at scala.None$.get(Option.scala:627)
at run$package$.run(get.scala:5)
.get throws, which is exactly the failure Option existed to prevent — a null pointer
exception with extra steps. Every appearance of .get in a codebase is a place the type
system was overruled.
Chaining
case class Customer(id: Int, name: String, countryCode: String)
case class Country(code: String, name: String, vatRate: Double)
val customerById = Map(
1 -> Customer(1, "Ada Lovelace", "GB"),
2 -> Customer(2, "Grace Hopper", "US"),
3 -> Customer(3, "Kim Lee", "XX"),
)
val countryByCode = Map(
"GB" -> Country("GB", "United Kingdom", 0.20),
"US" -> Country("US", "United States", 0.00),
)
def vatRateFor(customerId: Int): Option[Double] =
customerById.get(customerId).flatMap(c => countryByCode.get(c.countryCode)).map(_.vatRate)
@main def run(): Unit =
println(vatRateFor(1))
println(vatRateFor(3))
println(vatRateFor(99))
Some(0.2)
None
None
Two different failures — a customer in an unknown country, and a customer who does not exist
— both arrive as None. That is Option’s limitation, and the reason for Either.
flatMap rather than map because the function itself returns an Option; map would give
Option[Option[Country]].
The same thing as a for-comprehension, which reads better once there are three or more steps:
def vatRateFor(customerId: Int): Option[Double] =
for
customer <- customerById.get(customerId)
country <- countryByCode.get(customer.countryCode)
yield country.vatRate
Some(0.2)
None
None
Identical behaviour — a for-comprehension is flatMap and map with nicer syntax. It
short-circuits: the moment a step is None, the rest is skipped.
Either, when the reason matters
case class OrderRequest(customerId: Int, amount: Double, country: String)
def validate(r: OrderRequest): Either[String, OrderRequest] =
if r.amount <= 0 then Left(s"amount must be positive, got ${r.amount}")
else if !countryByCode.contains(r.country) then Left(s"unknown country ${r.country}")
else if !customerById.contains(r.customerId) then Left(s"no customer ${r.customerId}")
else Right(r)
@main def run(): Unit =
val requests = List(
OrderRequest(1, 25.50, "GB"),
OrderRequest(1, -5.00, "GB"),
OrderRequest(1, 10.00, "ZZ"),
OrderRequest(99, 10.00, "GB"),
)
requests.map(validate).foreach {
case Right(r) => println(s"accepted: ${r.customerId} £${r.amount}")
case Left(err) => println(s"rejected: $err")
}
accepted: 1 £25.5
rejected: amount must be positive, got -5.0
rejected: unknown country ZZ
rejected: no customer 99
By convention Right is success and Left is the error. Either is right-biased, so map,
flatMap and for-comprehensions operate on the success value and pass a Left through
untouched:
def total(r: OrderRequest): Either[String, Double] =
for
valid <- validate(r)
rate <- countryByCode.get(valid.country).map(_.vatRate)
.toRight(s"no vat rate for ${valid.country}")
yield valid.amount * (1 + rate)
@main def run(): Unit =
println(total(OrderRequest(1, 25.50, "GB")))
println(total(OrderRequest(1, -5.00, "GB")))
Right(30.599999999999998)
Left(amount must be positive, got -5.0)
toRight converts an Option into an Either by supplying the error — the bridge between
the two, and where you attach a reason to a plain lookup miss.
Try, for code that throws
import scala.util.{Try, Success, Failure}
@main def run(): Unit =
val inputs = List("25.50", "n/a", "12.00", "")
val parsed = inputs.map(s => Try(s.toDouble))
parsed.foreach {
case Success(d) => println(s"parsed $d")
case Failure(e) => println(s"failed: ${e.getClass.getSimpleName}: ${e.getMessage}")
}
println(parsed.collect { case Success(d) => d }.sum)
println(inputs.flatMap(_.toDoubleOption).sum)
parsed 25.5
failed: NumberFormatException: For input string: "n/a"
parsed 12.0
failed: NumberFormatException: empty String
37.5
37.5
Try captures a thrown exception as a value, which is how you wrap a Java library that
throws. For the specific case of parsing, the standard library already has
toDoubleOption / toIntOption — cleaner than Try when you do not need the exception.
Converting between them is direct:
println(Try("n/a".toDouble).toOption)
println(Try("n/a".toDouble).toEither.left.map(_.getMessage))
None
Left(For input string: "n/a")
Failing fast or collecting everything
def parseAll(inputs: List[String]): Either[String, List[Double]] =
inputs.foldRight(Right(Nil): Either[String, List[Double]]) { (s, acc) =>
for
rest <- acc
value <- s.toDoubleOption.toRight(s"cannot parse '$s'")
yield value :: rest
}
def parseCollectingErrors(inputs: List[String]): (List[String], List[Double]) =
val results = inputs.map(s => s.toDoubleOption.toRight(s"cannot parse '$s'"))
(results.collect { case Left(e) => e }, results.collect { case Right(v) => v })
@main def run(): Unit =
println(parseAll(List("1.5", "2.5")))
println(parseAll(List("1.5", "n/a", "", "2.5")))
println(parseCollectingErrors(List("1.5", "n/a", "", "2.5")))
Right(List(1.5, 2.5))
Left(cannot parse '')
(List(cannot parse 'n/a', cannot parse ''),List(1.5, 2.5))
The first stops at the first failure and reports one problem — note it reports '', not
'n/a', because foldRight works from the right. Fine for a pipeline that must abort;
frustrating for a user filling in a form, who wants all the errors at once. The second keeps
both lists, which is what a validation endpoint should return.
A rule of thumb
| Situation | Use |
|---|---|
| lookup that may legitimately miss | Option |
| validation or parsing where the reason matters | Either[Error, A] |
| wrapping a library that throws | Try |
| genuinely unrecoverable (bad config at startup) | throw |
Do not model expected outcomes as exceptions. “No such customer” is a value, not an exceptional event, and making it one means the caller cannot see it in the type.
Practice
1. Chain two lookups where the second can fail.
val rate = for
c <- customerById.get(3)
k <- countryByCode.get(c.countryCode)
yield k.vatRate
println(rate)
None
The customer exists but their country does not, and the chain short-circuits. Option cannot
tell you which step failed — that is the moment to switch to Either.
2. Call .get on a None.
Exception in thread "main" java.util.NoSuchElementException: None.get
The same crash Option was meant to prevent. getOrElse, fold or a match all handle it
without a throw, and one of them always fits.
3. Convert an Option into an Either with a message.
println(customerById.get(99).toRight("no such customer"))
println(customerById.get(1).toRight("no such customer").map(_.name))
Left(no such customer)
Right(Ada Lovelace)
toRight is where a bare absence gains a reason. Do it at the boundary of your code, so
everything downstream carries the context.
4. Collect all validation errors instead of the first.
val (errors, values) = parseCollectingErrors(List("1.5", "n/a", "", "2.5"))
println(s"${errors.size} errors, ${values.size} values")
errors.foreach(println)
2 errors, 2 values
cannot parse 'n/a'
cannot parse ''
Both errors, and the valid rows too. A for-comprehension cannot do this — it short-circuits by design — so collecting requires mapping first and partitioning after.
Next: functions as values — higher-order functions, closures, and composition.