Option, Either and Try in Scala: Handling Absence and Failure Without Null
Tony Hoare, who invented the null reference in 1965, later called it his โbillion-dollar mistakeโ โ a null pointer can hide behind any reference type in most languages, and the compiler never forces you to check for it, so it surfaces as a runtime crash at the worst possible moment. Scalaโs answer is to make โthis might not have a valueโ and โthis might failโ explicit in the type system โ Option, Either, and Try โ so the compiler forces you to handle the missing/failure case at compile time, not discover it at runtime.
Option โ Explicit Absence Instead of Null
def findUser(id: String): Option[String] = { val users = Map("1" -> "Alice", "2" -> "Bob") users.get(id)}
findUser("1") // Some("Alice")findUser("99") // NoneOption[T] has exactly two possible values: Some(value) (a value is present) or None (nothing is present) โ and critically, findUserโs return type tells the caller, in the type signature itself, that a user might not be found. This is fundamentally different from a Java method returning String that might secretly return null โ nothing in that Java signature warns you, and nothing forces you to check before using the result. Option[T] makes the possibility of absence part of the contract, checked by the compiler.
val result = findUser("99")result.length // Compile error โ Option[String] has no .length method; you must unwrap it firstThis is the actual safety mechanism: you cannot accidentally call a String method directly on an Option[String] โ the compiler forces you to explicitly handle both cases before getting to the underlying value, eliminating the entire โforgot to null-checkโ class of bug structurally, not just by convention.
Safely extracting a value
val name1 = findUser("1").getOrElse("Unknown") // "Alice"val name2 = findUser("99").getOrElse("Unknown") // "Unknown" โ fallback used
findUser("1").isDefined // truefindUser("99").isEmpty // true
findUser("1") match { case Some(name) => println(s"Found: $name") case None => println("Not found")}getOrElse is the most common way to unwrap an Option with a sensible default; pattern matching is the most explicit way when both branches need genuinely different handling, not just a fallback value.
Chaining operations without ever checking isDefined manually
def findUser(id: String): Option[String] = Map("1" -> "Alice").get(id)def findEmail(name: String): Option[String] = Map("Alice" -> "alice@example.com").get(name)
val email = findUser("1").flatMap(findEmail)// Some("alice@example.com") โ chains two Option-returning lookups without any manual null/isDefined checks
val email2 = findUser("1") .map(_.toUpperCase) .flatMap(findEmail) .getOrElse("no-email@example.com")This is exactly the flatMap-on-Option idiom previewed in the collection operations guide: Option behaves as a zero-or-one-element collection, so map/flatMap chain naturally โ if any step in the chain produces None, every subsequent step is automatically skipped, and the whole chain short-circuits to None without any explicit if checks at each step.
Either โ Success or Failure, With a Reason
def parseAge(input: String): Either[String, Int] = { input.toIntOption match { case Some(age) if age >= 0 => Right(age) case Some(_) => Left("Age cannot be negative") case None => Left(s"'$input' is not a valid number") }}
parseAge("30") // Right(30)parseAge("-5") // Left("Age cannot be negative")parseAge("abc") // Left("'abc' is not a valid number")Either[L, R] generalizes Option for the case where you need to explain why something failed, not just that it did โ by strong convention, Left carries the failure/error case and Right carries the success case (a mnemonic: โrightโ also means โcorrectโ). This is the idiomatic Scala replacement for exceptions in ordinary business logic โ validation, parsing, anything with an expected, describable failure mode that the caller should handle as a normal code path, not an exceptional one.
// Chaining Either the same way as Optiondef validateAge(age: Int): Either[String, Int] = if (age <= 150) Right(age) else Left("Age seems implausible")
val result = parseAge("30").flatMap(validateAge)// Right(30) โ both validations passed
val result2 = parseAge("-5").flatMap(validateAge)// Left("Age cannot be negative") โ short-circuits at the first failure, validateAge never runsJust like Option, Either short-circuits on the first Left in a chain of flatMap calls โ this is exactly why Either (specifically its flatMap) is the standard building block for validation pipelines with multiple sequential checks, each of which can fail with its own specific error message.
// Pattern matching to handle both branches explicitlyparseAge("abc") match { case Right(age) => println(s"Valid age: $age") case Left(error) => println(s"Error: $error")}Try โ Wrapping Code That Might Throw
import scala.util.{Try, Success, Failure}
def parseNumber(s: String): Try[Int] = Try(s.toInt)
parseNumber("42") // Success(42)parseNumber("abc") // Failure(java.lang.NumberFormatException: For input string: "abc")
parseNumber("42") match { case Success(n) => println(s"Parsed: $n") case Failure(ex) => println(s"Failed: ${ex.getMessage}")}Try[T] wraps a computation that might throw an exception, capturing the result as either Success(value) or Failure(exception) โ the specific value of Try is bridging existing, exception-throwing Java/Scala APIs (like String.toInt, which throws NumberFormatException) into the same safe, chainable style as Option/Either, without you needing to wrap every call in a manual try/catch block.
val result = parseNumber("10") .map(_ * 2) .flatMap(n => if (n > 0) Success(n) else Failure(new IllegalArgumentException("Must be positive"))) .getOrElse(0)Try supports the identical map/flatMap/getOrElse chaining vocabulary as Option and Either โ this consistency across all three types is deliberate, letting you learn one chaining pattern and apply it regardless of which of the three youโre working with.
Converting between the three
val opt: Option[Int] = parseNumber("42").toOption // Try -> Option, discarding the exception detailval either: Either[Throwable, Int] = parseNumber("42").toEither // Try -> Either, keeping the exception as Leftval tryFromOption: Try[Int] = Try(opt.get) // Option -> Try (risky โ .get throws on None)Converting between the three is common in real code: Try.toOption when you donโt care why something failed, only whether it succeeded; Try.toEither when you want to propagate the failure reason further as an Either for uniform handling alongside other validation logic.
When to Use Which
| Type | Represents | Use it when |
|---|---|---|
Option[T] | Present or absent, no reason needed | Looking up a value that might not exist (map lookup, find in a list, optional config field) |
Either[L, R] | Success or failure, with an explanation | Validation, business-rule checks, anything needing a specific, meaningful error message |
Try[T] | Success or a caught exception | Wrapping calls to exception-throwing APIs (parsing, I/O, interop with Java libraries) |
A common, idiomatic real-world pipeline uses all three together: Try to safely call an exception-throwing library function, .toEither to convert the caught exception into your own domain-specific error type, then flatMap-chaining that Either with further Either-returning validation logic โ each type used exactly where its specific strength applies.
Common Patterns
Collecting only the present values from a list of Options
val inputs = List("1", "2", "abc", "4")val parsed: List[Option[Int]] = inputs.map(_.toIntOption)val valid: List[Int] = parsed.flatten// List(1, 2, 4) โ .flatten on a List[Option[T]] drops every None and unwraps every Some
// Equivalent, more direct one-step version using flatMap (from the collection operations guide)val validDirect = inputs.flatMap(_.toIntOption) // List(1, 2, 4)Requiring every value to be present, or failing entirely
val inputs = List("1", "2", "3")val allParsed: Option[List[Int]] = inputs.foldLeft(Option(List.empty[Int])) { (accOpt, s) => for { acc <- accOpt n <- s.toIntOption } yield acc :+ n}// Some(List(1, 2, 3)) if every element parses; None if even one fails
// The standard-library shortcut for exactly this caseval sequenced: Option[List[Int]] = inputs.map(_.toIntOption).reduceOption((a, b) => for { x <- a; y <- b } yield x)This โall must succeed, or the whole thing failsโ pattern โ turning a List[Option[T]] into an Option[List[T]] โ comes up often enough in validation logic that libraries like Cats provide a direct sequence method for it; the manual foldLeft version above shows what that convenience is actually doing underneath.
Recovering from a failure with a default computation
import scala.util.{Try, Success, Failure}
def fetchFromCache(key: String): Try[String] = Failure(new RuntimeException("cache miss"))def fetchFromDatabase(key: String): String = s"value-for-$key"
val result = fetchFromCache("user:1").recover { case _: RuntimeException => fetchFromDatabase("user:1")}// Success("value-for-user:1") โ recover turns a Failure into a Success using a fallbackrecover is Tryโs equivalent of getOrElse for cases where the fallback itself might reasonably depend on which exception occurred, rather than always being the same fixed default value.
Why Not Just Use Exceptions Everywhere?
// Exception-based โ the possibility of failure is invisible in the type signaturedef parseAgeUnsafe(input: String): Int = input.toInt // no hint this can throw!
// Callers have no compile-time signal to handle the failure case at allval age = parseAgeUnsafe("not a number") // crashes at runtime, with zero warning from the type checkerThe core problem with unchecked exceptions (which is what Javaโs RuntimeException hierarchy and Scalaโs default exception model both are) is that a functionโs signature gives no indication it might throw โ parseAgeUnsafe(input: String): Int looks exactly like a function that always succeeds, and the only way to discover otherwise is documentation, tribal knowledge, or a crash in production. Option/Either/Try make the possibility of failure a visible, checked part of the type signature โ a caller literally cannot get an Int out of parseAge without going through code that acknowledges failure might have happened, whether via getOrElse, pattern matching, or further chaining.
Frequently Asked Questions
Should I ever use null in Scala? Essentially never in idiomatic code โ Option exists specifically to replace it, and Scalaโs own standard library and ecosystem libraries consistently use Option rather than null for โmight be absentโ values. null remains present (Scala runs on the JVM, and Java interop can hand you nulls) but should be converted to Option at the boundary (Option(javaNullableValue), which correctly produces None if the value is null) rather than propagated through your own code.
Whatโs the actual difference between Option and Either if both can represent โnothingโ? Optionโs None carries no information about why โ itโs binary, present or absent. Eitherโs Left carries an actual value (typically an error message or error type) explaining the failure, which is the entire reason to reach for Either over Option whenever the caller needs to know more than just โit didnโt work.โ
Does wrapping everything in Try eliminate the need to ever write a try/catch block? Mostly, for the common case of โcall a single throwing function and represent its result safelyโ โ but genuinely complex exception handling (different behavior per exception type, resource cleanup needing finally) may still be clearer as an explicit try/catch, with the result converted to Try/Either immediately afterward for the rest of the pipeline.
Is there a performance cost to using Option/Either/Try instead of null/exceptions? A small, generally negligible allocation cost for wrapping a value in Some/Right/Success โ in exchange for compiler-enforced handling of the failure case. For the overwhelming majority of application code, this trade-off strongly favors safety; only in extremely hot, allocation-sensitive loops would this cost be worth specifically avoiding.
Can I chain Option, Either, and Try together with a for comprehension instead of explicit flatMap calls? Yes โ this is exactly what the for-comprehensions guide covers, and itโs the more common, more readable way to write a multi-step chain of any of these three types in real code, rather than a long chain of explicit .flatMap(...).flatMap(...) calls.
Whatโs Next
Option, Either, and Try make absence and failure explicit, type-checked parts of your code rather than hidden runtime surprises. Next: Pattern Matching in Scala, covering match expressions, extractors, and sealed trait exhaustiveness checking in full depth โ building directly on the Some/None/Right/Left pattern matching already used throughout this guide.