Option, Try, and Either: typed failure
Lesson 10 gave us the monad: flatMap sequences context-carrying steps, and a for-comprehension reads that sequence like ordinary code while the context (short-circuiting, threading) is handled for us. This lesson cashes that machinery out into the three types you will reach for every single day. The one new idea: failure can be a value with a type. Instead of a function that secretly returns null or throws, the failure rides home in the return type — and because these failure types are monads, the failures compose through the very for-comprehensions you just learned.
null, thrown exceptions) to failure represented as data in the type, using Option, Either, and Try. The conceptual arc — these are the monads of Lesson 10, specialized to the absence / typed-error / captured-exception cases — is the book's; the examples and traces here are original.flatMap, for-comprehensions) — these three types are monads, so you already know how they sequence. Also Lesson 04 (referential transparency) and Lesson 06 (total vs. partial functions), whose vocabulary names exactly what null and exceptions break.New capability: replace
null and thrown exceptions with typed failure values that compose in for-comprehensions, and choose correctly among Option (absence), Either (typed error with a reason), and Try (capture a thrown exception).null and exceptions are invisible in the signature, so a type lies. (2) Option[A] for absence. (3) Either[E,A] for a typed error channel that carries why. (4) Try[A] to capture a thrown exception as a value — the bridge to legacy code. (5) A which-when decision table. (6) Compose them: a for-comprehension over Either where the first failure wins, with a worked parseConfig trace; then name-drop accumulating errors (deferred to Lesson 14's ecosystem).1 · The disease: a return type that lies
Consider this signature:
def find(id: Long): User // looks total: "give me an id, get a User"
The type says: every Long maps to a User. The type is lying. In practice the implementation does one of two invisible things when the user is missing: it returns null (so the "User" you got back is secretly not a User at all, and your next .name throws a NullPointerException three call-frames away), or it throws an exception (so the function does not "return a User" — it abruptly transfers control somewhere you cannot see from the call site).
Both are failures of the same kind we named earlier. In Lesson 06 we called a function partial if it is undefined on some inputs; find is partial (no user for that id) but wears the costume of a total function. And in Lesson 04 we defined a referentially transparent expression as one you can replace with its value without changing meaning. You cannot substitute find(id) with "a User," because sometimes there is no value to substitute — there is a null landmine or a thrown control-flow jump. The signature hides the very thing you must handle.
The cure is a single idea: make the failure a value in the return type. Change the type so it honestly says "this might not produce an A." Then the compiler will not let the caller ignore it, the function becomes total (every input maps to some well-defined result), and — the payoff — these honest types are the monads of Lesson 10, so the failure threads through compositions automatically.
2 · Option[A] — modelling absence
Option[A] is a sum type (Lesson 06) with exactly two shapes: Some(a) holds a value of type A; None holds nothing. It models absence — "there might be no A here" — and carries no detail about why. That is its whole job.
def find(id: Long): Option[User] // honest: maybe a User, maybe not
find(7) match {
case Some(user) => user.name // the value is right here, unwrapped and safe
case None => "unknown" // the compiler made us handle this branch
}
The standard library already speaks Option wherever a lookup can fail. These are the total versions of partial operations:
List(1,2,3).headOption // Some(1); Nil.headOption == None (head would throw)
Map("a" -> 1).get("a") // Some(1)
Map("a" -> 1).get("z") // None (no exception, no null)
"42".toIntOption // Some(42); "oops".toIntOption == None
To get a plain value out with a default, use getOrElse (never .get — see §6):
val name = find(7).map(_.name).getOrElse("anonymous")
Crucially, Option is a monad (Lesson 10): it has map (Lesson 08's functor operation) and flatMap, and any chain short-circuits on None — the moment a step produces None, the rest is skipped and the whole result is None. Mirror the lookup chain from Lesson 10 — user → account → balance, where each step might be absent:
def account(u: User): Option[Account]
def balance(a: Account): Option[Money]
val result: Option[Money] =
for {
u <- find(id) // Option[User]
a <- account(u) // Option[Account]
b <- balance(a) // Option[Money]
} yield b
// if ANY step is None, result is None — no null check written anywhere
That for is exactly the flatMap/map desugaring from Lesson 10; the "if it's missing, stop" logic lives in Option's flatMap, not in your code.
3 · Either[E, A] — a typed error channel
Option tells you that something is absent but never why. When the caller needs the reason — to show a message, to branch on the error, to log it — use Either[E, A]. It too is a two-shape sum type: Right(a: A) is the success, Left(e: E) is the failure carrying a value of type E that explains it (a message, an error enum, anything). Mnemonic: Right is "right" / correct; Left is what's left over when things go wrong.
Scala's Either is right-biased: map and flatMap operate on the Right and pass any Left through untouched. So a for-comprehension over Either short-circuits on the first Left and preserves its error value — you find out not just that it failed but precisely which step failed and why.
def parseInt(s: String): Either[String, Int] =
s.toIntOption.toRight(s"not a number: '$s'") // Some->Right, None->Left(msg)
def positive(n: Int): Either[String, Int] =
if (n > 0) Right(n) else Left(s"must be positive: $n")
val good = for { n <- parseInt("42"); p <- positive(n) } yield p // Right(42)
val bad1 = for { n <- parseInt("oops"); p <- positive(n) } yield p // Left("not a number: 'oops'")
val bad2 = for { n <- parseInt("-5"); p <- positive(n) } yield p // Left("must be positive: -5")
Note how bad1 short-circuits at the first step — positive never runs, and the original message survives in the Left. That is the whole advantage over Option: the failure has a type and a payload, so the caller can act on it.
4 · Try[A] — capturing a thrown exception
Sometimes the failure is not yours to design — you are calling legacy or Java code that throws. Try[A] (from scala.util) captures that: Try { expr } runs expr, and if it throws, the exception becomes a value instead of unwinding the stack. Its two shapes are Success(a) and Failure(t: Throwable).
import scala.util.{Try, Success, Failure}
val parsed: Try[Int] = Try { Integer.parseInt(userInput) } // may throw NumberFormatException
parsed match {
case Success(n) => n
case Failure(e) => { log(e); 0 }
}
// Try is a monad too, so it composes and short-circuits on the first Failure:
val cfg = for {
port <- Try(Integer.parseInt(portStr))
conn <- Try(openSocket(port)) // a throw here becomes Failure, stops the chain
} yield conn
Think of Try[A] as Either[Throwable, A] with the left side fixed to Throwable and a built-in "run this block and catch" constructor. It is the bridge at the boundary: wrap throwing code in Try at the edge of your functional core (Lesson 00) so that everything inside stays in the world of values. (Where you need a domain error rather than a raw Throwable, convert: parsed.toEither.left.map(_.getMessage) gives an Either[String, Int].)
5 · Which one, when
All three are monads, so they all compose the same way; they differ only in what the failure carries. Choose by what the caller needs to know:
| Type | Failure shape | Use when… | Example |
|---|---|---|---|
Option[A] | None — no detail | it might be absent and the caller does not need a reason | map.get(key), list.headOption |
Either[E,A] | Left(e) — your typed reason | it might fail and the caller needs the why (message, error enum) | validation, parsing with diagnostics |
Try[A] | Failure(throwable) | you are wrapping code that throws (legacy/Java) at the boundary | Try { riskyJavaCall() } |
A quick rule: reach for Option first (it is the lightest); upgrade to Either the moment the caller would ask "why did it fail?"; use Try only to catch a thrown exception and immediately turn it into a value.
6 · Composition and short-circuit — a worked trace
Here is parseConfig: it reads three fields from a raw map, each parse returning Either[String, T], and combines them in one for-comprehension. Because Either is right-biased, the first Left wins and the remaining steps are skipped.
final case class Config(host: String, port: Int, retries: Int)
def field(raw: Map[String,String], key: String): Either[String, String] =
raw.get(key).toRight(s"missing field: $key")
def parseConfig(raw: Map[String,String]): Either[String, Config] =
for {
host <- field(raw, "host") // Either[String, String]
portS <- field(raw, "port")
port <- portS.toIntOption.toRight(s"bad port: $portS") // Either[String, Int]
retries <- field(raw, "retries").flatMap(s =>
s.toIntOption.toRight(s"bad retries: $s"))
} yield Config(host, port, retries)
raw = Map("host"->"db1","port"->"5432","retries"->"3"). Step 1 field("host") = Right("db1"), flatMap continues with host="db1". Step 2 Right("5432"). Step 3 "5432".toIntOption = Some(5432) → Right(5432), port=5432. Step 4 Right(3). The yield runs: Right(Config("db1",5432,3)). Every step was Right, so flatMap threaded each value into the next.Failure.
raw = Map("host"->"db1","port"->"x","retries"->"3"). Step 1 Right("db1"). Step 2 Right("x"). Step 3 "x".toIntOption = None → Left("bad port: x"). Now Either's flatMap sees a Left and stops: step 4 never runs, the yield never runs, and the whole result is Left("bad port: x") — the exact reason, preserved, with no if/throw written anywhere. (If "host" had been missing, you would get Left("missing field: host") instead, and nothing after step 1 would execute.)One failure wins — what about reporting all of them? A for-comprehension is sequential: it threads each step's value into the next, so it must stop at the first failure (it has nothing to feed the rest). That is exactly right for dependent steps, but if you want to validate independent fields and collect every error ("port is bad AND retries is bad"), you need a different tool — an applicative, often the Validated type from the Cats library, which combines failures instead of short-circuiting. That belongs to the ecosystem; we defer the mechanism to Lesson 14. For now: for over Either = first failure wins.
Common mistakes / failure modes
.get on Option/Tryopt.get throws on None; tryV.get rethrows the captured exception. That re-creates the exact disease (§1) you used the type to cure. Use getOrElse, map, pattern match, or thread it in a for. Treat .get as a code smell.Option/Either. Reserve exceptions (and Try) for truly exceptional, often-foreign failures.Option when the caller needs the reasonNone from a validator throws away why it failed, so the caller can only say "something was wrong." If anyone downstream needs to report or branch on the cause, that is an Either (§3, §5).Option[Either[…]] carelesslyflatMap together, so the for won't compose. Pick one channel and convert at the seam — opt.toRight(err), tryV.toEither, either.toOption — so a chain stays in a single monad.Checkpoint exercise
def lookupRate(currency: String): Double which currently returns null... wait — it can't, Double isn't nullable, so today it throws new NoSuchElementException when the currency is unknown. Rewrite it to return a typed failure. Decide between Option[Double] and Either[String, Double], and write one sentence justifying the choice (hint: does any caller need to show the user which currency was unknown? If yes → Either; if a silent fallback rate is fine → Option).(b) Given
parseInt(s): Either[String,Int] and positive(n): Either[String,Int] from §3, write a for-comprehension that parses "-9" then checks it is positive, and state the exact result — including which step ran, which did not, and what the Left contains. (Answer: parseInt("-9") = Right(-9) runs; positive(-9) = Left("must be positive: -9"); the yield never runs; result is Left("must be positive: -9").)Where this points next
We now have honest data (immutable values, Lesson 03) and honest failure (typed, composable, Lesson 11) — the full vocabulary of a value. But we have been quietly using collections and chained operations without asking how an immutable program iterates: with no mutable loop counter, how do you walk a list or build one up? Lesson 12 answers with recursion as the functional loop — structural recursion, tail recursion with @tailrec for stack safety, the accumulator pattern — and then closes a worry first raised back in Lesson 03: it introduces persistent data structures whose structural sharing makes "build a new value to update" cheap rather than wasteful, finally cashing the promise that immutability need not mean copying everything.
null and thrown exceptions are invisible in the type: a signature like def find(id): User lies, hiding a partial, non-substitutable function behind a total-looking face and breaking the reasoning guarantees of Lessons 04 and 06. The cure is to make failure a value in the return type. Option[A] (Some/None) models absence with no detail; Either[E,A] (Right/Left) is a typed error channel carrying why, right-biased so a for-comprehension short-circuits on the first Left and preserves it; Try[A] (Success/Failure) captures a thrown exception as a value — like Either fixed to Throwable — and is the bridge to throwing legacy code at the boundary. Choose by what the caller needs (absence → reason → captured throwable). Because all three are the monads of Lesson 10, they compose through for-comprehensions with no error-handling code written by hand; the first failure wins, and collecting all failures needs an applicative (Validated), deferred to Lesson 14. Never call .get — that resurrects the disease.Interview prompts
- Why are
nulland thrown exceptions considered worse than a typed failure value? (§1 — they are invisible in the signature, so a function looks total while being partial; this breaks referential transparency and the failure surfaces at runtime far from its cause, with the compiler unable to force handling.) - When would you choose
OptionoverEither, and vice versa? (§2, §3, §5 —Optionwhen something may be absent and the caller needs no reason;Eitherwhen the caller needs the typed why to display, log, or branch on.) - What does it mean that Scala's
Eitheris "right-biased," and what does afor-comprehension over it do on failure? (§3, §6 —map/flatMapact onRightand passLeftthrough; the comprehension short-circuits on the firstLeft, skips the rest, and preserves that error value.) - How does
Tryrelate toEither, and when isTrythe right tool? (§4 —Try[A]is essentiallyEither[Throwable, A]with a "run-and-catch" constructor; use it to wrap code that throws, at the boundary, converting a thrown exception into a value.) - Why is calling
.geton anOptionorTryan anti-pattern? (§6 mistakes — it throws onNone/rethrows onFailure, reintroducing the invisible runtime failure the type existed to remove; usegetOrElse/map/pattern match/forinstead.) - A
for-comprehension overEitherreports only the first error. How would you collect all validation errors instead? (§6 — a monadicforis sequential and must stop at the first failure; collecting all errors needs an applicative such as Cats'Validated, which combines failures — deferred to Lesson 14.) - How do these three types connect back to Lesson 10? (§2–§4 — each is a monad: it has
map(functor, Lesson 08) andflatMap, obeys the monad laws, and therefore sequences infor-comprehensions with automatic short-circuiting — the daily payoff of the abstraction.)