04 · Option/Either for Error Handling¶
You've already seen Option in passing — the
Level 1 project used it to represent a
line that might fail to parse. This module treats it properly, alongside
Either and Try: three types that let you model "this might not work" as
data you can transform and pattern match on, instead of relying on
exceptions or null.
Option: modeling absence¶
Option[A] is either Some(value) or None — nothing else, and no null
in sight. It's the standard replacement for "this might not have a value."
def parseInt(s: String): Option[Int] = s.toIntOption
println(parseInt("42")) // Some(42)
println(parseInt("nope")) // None
Option supports the same transformation methods as collections, because
it's really "a list of at most one element":
println(parseInt("42").map(_ * 2)) // Some(84)
println(parseInt("nope").map(_ * 2)) // None -- map on None is a no-op
The trap: .get overuse¶
Option has a .get method that returns the value directly — and throws
NoSuchElementException if called on None. Reaching for .get defeats
the entire point of Option, reintroducing exactly the runtime crash it
exists to prevent:
val maybe: Option[Int] = None
try
maybe.get
catch
case e: NoSuchElementException => println(s"caught: ${e.getMessage}")
// caught: None.get
Use .getOrElse(default) for a safe fallback, .map/.flatMap to
transform without unwrapping, or pattern matching / a for loop to act only
when a value is present:
println(maybe.getOrElse(-1)) // -1
def half(n: Int): Option[Int] =
if n % 2 == 0 then Some(n / 2) else None
println(parseInt("6").flatMap(half)) // Some(3)
println(parseInt("7").flatMap(half)) // None -- 7 isn't even, half never runs
for n <- parseInt("10") do println(s"got $n") // got 10 -- runs only if Some
flatMap is essential here: parseInt("6").map(half) would give you
Some(Some(3)) — a nested Option — because half itself returns an
Option. flatMap flattens that one extra layer, exactly like it does for
List[List[A]].
Either: modeling failure with a reason¶
Option tells you whether something worked, but not why it failed.
Either[L, R] fixes that: by convention, Left carries the failure/error
and Right carries the success value ("Right is right," i.e. correct).
def safeDivide(a: Int, b: Int): Either[String, Int] =
if b == 0 then Left("division by zero") else Right(a / b)
println(safeDivide(10, 2)) // Right(5)
println(safeDivide(10, 0)) // Left(division by zero)
Either is right-biased: map, flatMap, and for-comprehensions all
operate on the Right case and pass a Left straight through untouched —
which is exactly the "stop on first error" behavior you usually want.
println(safeDivide(10, 0).map(_ * 100)) // Left(division by zero) -- map skipped
println(safeDivide(10, 0).left.map(_.toUpperCase)) // Left(DIVISION BY ZERO)
safeDivide(10, 0) match
case Right(v) => println(s"ok: $v")
case Left(err) => println(s"error: $err")
// error: division by zero
Chaining validations with Either and for¶
This is where Either earns its keep — validating several fields and
stopping at the first failure, with a message that says what went wrong:
case class User(name: String, age: Int)
def validateName(name: String): Either[String, String] =
if name.isBlank then Left("name cannot be blank") else Right(name)
def validateAge(age: Int): Either[String, Int] =
if age < 0 then Left("age cannot be negative")
else if age > 150 then Left("age unrealistically high")
else Right(age)
def makeUser(name: String, age: Int): Either[String, User] =
for
validName <- validateName(name)
validAge <- validateAge(age)
yield User(validName, validAge)
println(makeUser("Ada", 30)) // Right(User(Ada,30))
println(makeUser("", 30)) // Left(name cannot be blank)
println(makeUser("Bob", -5)) // Left(age cannot be negative)
Note that makeUser("", -5) would still only report the first problem
(the blank name) — a plain Either chain short-circuits at the first
Left rather than collecting every error. If you need "report all the
problems at once," that's what a validation-accumulating type
(cats.data.Validated and similar, outside the standard library) is for —
worth knowing the limitation exists even though it's beyond this course.
Try: wrapping code that throws¶
Some code — especially calls into Java libraries — throws exceptions rather
than returning Option/Either. Try wraps a computation and turns a
thrown exception into a Failure value instead of letting it propagate:
import scala.util.{Try, Success, Failure}
def riskyParse(s: String): Try[Int] = Try(s.toInt) // s.toInt throws on bad input
riskyParse("42") match
case Success(v) => println(s"parsed: $v") // parsed: 42
case Failure(e) => println(s"failed: ${e.getClass.getSimpleName}")
riskyParse("nope") match
case Success(v) => println(s"parsed: $v")
case Failure(e) => println(s"failed: ${e.getClass.getSimpleName}")
// failed: NumberFormatException
Try converts to the other two when you need to: .toOption collapses
Failure to None (discarding the exception detail), and .toEither keeps
the exception as the Left value:
println(riskyParse("nope").toOption) // None
println(riskyParse("nope").toEither)
// Left(java.lang.NumberFormatException: For input string: "nope")
Cleaning up a collection of Options¶
A common pattern: you've mapped a parsing function across a list and ended
up with a List[Option[A]]. .flatten drops every None and unwraps every
Some in one call:
val opts = List(Some(1), None, Some(3))
println(opts.flatten) // List(1, 3)
println(opts.flatten.sum) // 4
How It Actually Works¶
Option[A] is itself a sealed trait with exactly two subtypes: Some(a)
(a case class wrapping one value) and None (a case object — see
Level 1's case classes module). There's no
runtime magic distinguishing "a value" from "absence" beyond ordinary
subtype polymorphism and pattern matching — option.map(f) is defined as a
plain match: case Some(a) => Some(f(a)), case None => None. Because
Option is sealed, the compiler can exhaustiveness-check any match over
it, which is part of why idiomatic Scala reaches for Option instead of
null: the compiler can prove every case is handled, whereas nothing stops
a String typed value from silently being null at runtime and blowing up
later with a NullPointerException the compiler had no way to flag.
for-comprehensions over Option/Either (covered fully in Module
9) desugar to chained flatMap/map calls, so
for { a <- opt1; b <- opt2 } yield a + b becomes opt1.flatMap(a =>
opt2.map(b => a + b)). This is exactly why the whole chain short-circuits
on the first None/Left: flatMap on None is defined to just return
None without ever calling the function you passed it, so evaluation
never reaches the later steps — it's ordinary method dispatch, not special
control-flow.
Try wraps this same map/flatMap interface around a computation that
might throw, and its mechanism is a try/catch block around your
by-name argument at construction time: Try(risky()) evaluates risky()
immediately inside a try, wrapping a thrown exception in Failure(e) and
a normal result in Success(v). Because Failure and Success share
Try's map/flatMap interface with Option's Some/None, the same
short-circuiting chain logic applies — a Failure anywhere in a chain
propagates untouched to the end, same as None.
Cheat sheet¶
| Type | Success case | Failure case | Carries a reason? |
|---|---|---|---|
Option[A] |
Some(value) |
None |
no |
Either[L, R] |
Right(value) |
Left(error) |
yes |
Try[A] |
Success(value) |
Failure(exception) |
yes (an exception) |
| Need to... | Use |
|---|---|
| Provide a fallback value | .getOrElse(default) |
| Transform only if present/successful | .map(f) |
Chain calls that themselves return Option/Either |
.flatMap(f) or a for-comprehension |
| Convert between the three | .toOption, .toEither |
Drop Nones from a List[Option[A]] |
.flatten |
| Run code that might throw, capture the exception | Try(...) |
Exercise¶
Write def parsePositiveEven(s: String): Either[String, Int] that parses a
string to an Int (failing with "not a number" if it doesn't parse),
then checks it's positive ("must be positive"), then checks it's even
("must be even") — chain these with a for-comprehension over Either,
short-circuiting at the first failure. Test it against "8", "-4",
"7", and "abc", printing each result. Then write a second function that
takes a List[String] and returns only the values that parse successfully,
using .flatMap with your Level-1-style Option-returning helper or by
converting your Either results with .toOption and .flatten.