all_lessons/functional programming/11 · Option, Try, Eitherlesson 12 / 15

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.

Book source
This maps to Widman's treatment of Learning Functional Programming's "error handling without exceptions" material — the move from invisible failure (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.
Linear position
Prerequisite: Lesson 10 (monads, 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).
The plan
Six moves. (1) Diagnose the disease — 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.

Why this is the billion-dollar mistake
Tony Hoare, who introduced the null reference in 1965, later called it his "billion-dollar mistake": it makes every reference type secretly nullable, so the compiler can never remind you to handle absence — the failure shows up only at runtime, far from its cause. Exceptions used for ordinary "not found" or "invalid input" cases have the same defect: the unhappy path is absent from the type, so the caller is never forced — or even invited — to deal with it.

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:

TypeFailure shapeUse when…Example
Option[A]None — no detailit might be absent and the caller does not need a reasonmap.get(key), list.headOption
Either[E,A]Left(e) — your typed reasonit 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 boundaryTry { 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)
Worked example — trace one success and one failure
Success. 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 = NoneLeft("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

Calling .get on Option/Try
opt.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.
Exceptions for ordinary control flow
Throwing on "not found" or "invalid input" hides the unhappy path from the type and is slow. Those are expected outcomes — model them with Option/Either. Reserve exceptions (and Try) for truly exceptional, often-foreign failures.
Option when the caller needs the reason
Returning None 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).
Nesting Option[Either[…]] carelessly
Mixing failure types stacks contexts that do not flatMap 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

Try it
(a) Take this lying signature: 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.

Takeaway
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