Skip to main content
Scala beginner Lesson 4 of 10

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

SituationUse
lookup that may legitimately missOption
validation or parsing where the reason mattersEither[Error, A]
wrapping a library that throwsTry
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.

Frequently Asked Questions

What is Option in Scala?
A container that is either `Some(value)` or `None`. It makes 'this might not be there' part of the type, so the compiler forces you to handle the empty case instead of discovering it as a null pointer exception at runtime.
When should I use Either instead of Option?
When the caller needs to know *why* something failed. `Option` says a value is absent; `Either[Error, A]` carries a reason in the `Left`. Use `Option` for a lookup that can legitimately miss, `Either` for validation and parsing.
Is it ever acceptable to call .get on an Option?
Rarely. It throws on `None`, which discards the guarantee the type gave you. Use `getOrElse`, `fold`, a `match`, or keep chaining with `map`. Reach for `.get` only where you have just proved non-emptiness, and prefer restructuring so you do not have to.
What does a for-comprehension do with Option?
It desugars to `flatMap` and `map`, so a chain of `Option` steps short-circuits to `None` as soon as any step is empty. The same syntax works for `Either`, `Try`, `Future` and collections, which is why it is worth learning once.