Option is the first functional type most Scala programmers meet, and the one they use most. It replaces a nullable reference with a value that says, in its type, that it might be absent: Some(value) or None. The compiler then stops you treating a maybe-absent value as present, which removes the NullPointerException from code that only uses Option, and makes absence a thing you handle deliberately rather than a crash you discover in production.

That much is in every tutorial. This page goes further: what the type actually is, the one place it does not protect you from null, which combinator to reach for for each job, how to chain several lookups, how to turn a list of Options into an Option of a list, when Option stops being enough and Either should take over, and how it meets Java code. Every printed result below is real output from one program compiled with scalac 2.13.16 and run on JDK 23. When a missing value needs a reason, continue with Scala Either, in depth; when the failure is an exception, with Scala Try.

Advertisement

The type, from first principles

Option is a sealed abstract class with exactly two cases. Some wraps a value; None is a single object that represents absence. Because the class is sealed, the compiler knows these are the only cases and warns when a pattern match misses one. Because the type parameter is covariant, written +A, an Option[Cat] can be used where an Option[Animal] is expected, and None, which extends Option[Nothing], fits every Option type, since Nothing is a subtype of everything.

// Simplified from the Scala 2.13 standard library
sealed abstract class Option[+A]
final case class Some[+A](value: A) extends Option[A]
case object None extends Option[Nothing]

object Option {
  def apply[A](x: A): Option[A] = if (x == null) None else Some(x)
  def when[A](cond: Boolean)(a: => A): Option[A] = if (cond) Some(a) else None
}

Two consequences follow. First, Option is a container with zero or one elements, so it behaves like a tiny collection: it has map, flatMap, filter, foreach and toList, and it can take part in for-comprehensions. Second, Option(x) and Some(x) are not the same. Option.apply checks for null and returns None; Some takes whatever you give it, including null. The general pattern of encoding cases in a sealed hierarchy is covered in sealed traits and algebraic data types.

Creating Options: the null boundary

Inside pure Scala code you rarely build Options by hand; library methods return them. Map.get, headOption, lastOption, find, collectFirst, String.toIntOption and PartialFunction.lift all return an Option instead of throwing or returning null. Option.when(cond)(value) builds Some only when a condition holds, and Option.unless does the opposite. Prefer these to if-else expressions that build Some and None manually.

Null enters from Java libraries, JDBC drivers, servlet APIs, system properties and deserialisers. That boundary is the one place Option needs care. Wrapping a possibly-null reference in Some does not remove the null, it hides it: the program below gets a null from System.getProperty, wraps it two ways, and only Option(...) produces None. Some(null) is a non-empty Option whose value is null, so the first map over it throws.

import scala.jdk.OptionConverters._

val fromJava: String = System.getProperty("no.such.property")   // null
Option(fromJava)                     // None
Some(fromJava)                       // Some(null): a trap
Some(fromJava).map(_.length)         // throws NullPointerException

val jopt: java.util.Optional[String] = Some("j").toJava
java.util.Optional.empty[String]().toScala                     // None

The rule is simple: at the boundary with Java, always use Option(x), never Some(x). Inside Scala code, construct Some only from values you know are not null. For java.util.Optional, the converters in scala.jdk.OptionConverters add toScala and toJava, as shown. Java's own design decisions for Optional, which are stricter than Scala's in some ways, are in Java Optional.

Advertisement

Getting a value out, and when the default runs

Pattern matching is the most explicit way to consume an Option, and it is right when the two branches do substantially different things. For the common case of a default, getOrElse is shorter. Its default parameter is by-name, written default: => B in the signature, so the default is evaluated only when the Option is empty. The demo counts calls to an expensive default: Some("x").getOrElse(expensive()) never calls it, None.getOrElse(expensive()) calls it once, and the program prints default evaluated = 1 time(s). That means a getOrElse that throws, logs or queries a database is safe and lazy, and orElse, which supplies a fallback Option, is lazy in the same way.

fold(ifEmpty)(f) combines both branches in one expression: the result of f applied to the value, or ifEmpty. It has an inference trap worth seeing once. Scala infers the result type from the first argument list, so fold(Nil) fixes the type as Nil.type, the type of the empty-list object, and List(_) in the second argument no longer fits:

val opt: Option[Int] = Some(1)
val xs = opt.fold(Nil)(List(_))

// scalac 2.13.16:
// error: type mismatch;
//  found   : List[Int]
//  required: collection.immutable.Nil.type

val ok1 = opt.fold(List.empty[Int])(List(_))   // compiles
val ok2 = opt.toList                           // simpler still

The fix is to give the empty case its full type, or to use a method that already has the right shape. Option.get also exists, and throws NoSuchElementException with the message None.get on an empty Option. Using it says you know better than the type; most codebases ban it outside tests, and Scalafix or Wartremover rules can enforce that.

The combinators, grouped by job

JobUseResult in the demo
Transform the value if presentmap(f)Some("ada").map(_.toUpperCase) gives Some(ADA)
Chain a step that may itself be absentflatMap(f), for-comprehensionSee managerEmail below
Keep the value only if a test passesfilter(p), filterNot(p)Some("ada").filter(_.length > 5) gives None
Collapse to a plain valuegetOrElse(d), fold(d)(f)fold(0)(_.length) gives 3
Supply another OptionorElse(o)None.orElse(Some("fallback")) gives Some(fallback)
Ask a yes/no questioncontains, exists, forall, isDefinedOn None, exists is false and forall is true
Combine two independent Optionszip, for-comprehensionSome(1).zip(Some("a")) gives Some((1,a))
Run a side effectforeach(f)Runs only for Some
ConverttoList, toRight(err), toJavaZero-or-one list, Either, Optional

Two rows deserve emphasis. exists and forall disagree on None, as they do for an empty list: no element satisfies the predicate, and no element violates it. Write the predicate so the empty case means what you want, and prefer contains for equality. And map versus flatMap is the whole skill of using Option: map when your function returns a plain value, flatMap when it returns another Option. Using map with an Option-returning function gives Option[Option[B]], which is almost always a sign you wanted flatMap.

Chaining lookups with a for-comprehension

Real code rarely has one optional value. It has a sequence of steps, each of which may find nothing. The worked example models a user directory: find a user, find their manager's id, find the manager, find the manager's email. Each step returns an Option, and a for-comprehension desugars into nested flatMap calls with a final map, so the chain stops at the first None.

final case class User(id: Long, name: String, managerId: Option[Long], email: Option[String])

val users: Map[Long, User] = Map(
  1L -> User(1, "Ada", None, Some("ada@example.com")),
  2L -> User(2, "Grace", Some(1L), None),
  3L -> User(3, "Linus", Some(9L), Some("linus@example.com"))
)

def managerEmail(id: Long): Option[String] =
  for {
    u   <- users.get(id)
    mid <- u.managerId
    m   <- users.get(mid)
    e   <- m.email
  } yield e
managerEmail(id): four lookups, one Optionusers.get(id)Option[User]flatMapu.managerIdOption[Long]flatMapusers.get(mid)Option[User]mapm.emailOption[String]NoneNoneNoneNoneNone: the chain stops at the first empty step and later steps never runid 2 (Grace): manager 1 (Ada) has an email, result Some(ada@example.com)id 1 (Ada): no manager, stops at step 2 with Noneid 3 (Linus): manager 9 does not exist, stops at step 3 with NoneThe result cannot say WHICH step failed. When the caller needs that, switch to Either.
Each arrow is a flatMap. Any None short-circuits the rest of the chain, which is exactly right for lookups, and exactly why the caller cannot tell which step was missing.

The program prints Some(ada@example.com) for Grace, and None for both Ada, who has no manager, and Linus, whose manager id points at a user that does not exist. Those are different situations, a normal top-level employee and a data integrity bug, and the Option result cannot distinguish them. That is not a flaw in Option, it is its contract: Option says whether there is a value, nothing more. If the caller needs to react differently, the chain should return Either instead.

Lists of Options: flatten or traverse

Parsing a list of strings into numbers produces a List[Option[Int]], and there are two different things you might want from it. Keeping the successes and dropping the failures is flatten, or more directly flatMap, because an Option behaves like a collection of zero or one. Getting all values or nothing, so one bad input invalidates the batch, is traverse. The Scala 2.13 standard library has no traverse for Option, so either write it with foldRight as below or use the version in Cats, where list.traverse(f) does the same thing for any applicative.

// All-or-nothing: Some(list) only if every element converts
def traverse[A, B](as: List[A])(f: A => Option[B]): Option[List[B]] =
  as.foldRight(Option(List.empty[B])) { (a, acc) =>
    for { b <- f(a); bs <- acc } yield b :: bs
  }

val raw = List("8080", "x", "9090")
raw.flatMap(_.toIntOption)                   // keep the good ones
traverse(List("1", "2"))(_.toIntOption)      // all good
traverse(raw)(_.toIntOption)                 // one bad element

On the three-element list, flatMap prints List(8080, 9090) and silently drops "x"; traverse prints None. Neither is wrong, but they encode different policies, and choosing flatMap by habit is how invalid configuration entries disappear without a trace. If you choose to drop bad values, count and log them. The general pattern behind traverse is explained in traverse and sequence.

When absence needs a reason: switching to Either

Option is the right return type when absence is normal and self-explanatory: a cache miss, an optional field, a search with no results. It is the wrong type when the caller must report or act on why. The conversion is cheap: toRight(error) turns Some into Right and None into Left with the error you supply, and the rest of the code continues in Either.

def parsePort(env: Map[String, String]): Either[String, Int] =
  env.get("PORT")
    .toRight("PORT is not set")
    .flatMap(s => s.toIntOption.toRight(s"PORT is not a number: $s"))
    .filterOrElse(p => p > 0 && p < 65536, "PORT out of range")

The output shows three distinguishable outcomes, Right(8080), Left(PORT is not set) and Left(PORT is not a number: eighty), where the Option version would have given Some(8080), None and None. A good rule: return Option from lookups and parsers of single values, and convert to Either at the first point where a human or a caller needs an explanation. Do not go the other way and flatten Either back into Option, because that discards the reason you just computed.

Option in data types, JSON and databases

As a field type, Option[A] says the field may be missing, and the major libraries map it that way. Circe decodes a missing JSON field or JSON null into None for an Option field; Doobie maps a nullable column to Option and a non-nullable one to the plain type, and reports a mismatch when a NULL arrives in a column read as non-optional. The design question is whether absent and null-valued should mean the same thing. For PATCH-style APIs they often should not: a field omitted means leave unchanged, a field set to null means clear it. Option cannot express three states, so model the update explicitly with a small sealed type of three cases instead of Option[Option[A]].

Avoid Option for parameters with sensible defaults. A method taking timeout: Option[Duration] forces every caller to write Some(...); a default parameter value is clearer. Also avoid Option[Boolean] and Option[List[A]] unless absence truly differs from false or from empty.

Performance

Some is an object allocation, and Option[Int] boxes the Int as well, so Option is not free. On the JVM short-lived allocations are cheap and escape analysis can remove some of them, so for ordinary business code the cost is invisible next to I/O. It matters in tight numeric loops and large arrays: an Array[Option[Double]] of ten million elements holds ten million references to separate objects. There, use a sentinel value with a clear name, a separate validity bitmap, or a specialised structure. Measure with a JMH benchmark before changing readable code for speed.

The complete output

This is the unedited output of the demo program, from which every result quoted above was taken.

Option(null)      = None
Some(null)        = Some(null)
Some(null).map    = Failure(java.lang.NullPointerException: Cannot invoke "String.length()" because "x$1" is null)
managerEmail(2)   = Some(ada@example.com)
managerEmail(1)   = None
managerEmail(3)   = None
default evaluated = 1 time(s)
map               = Some(ADA)
filter            = None
fold              = 3
orElse            = Some(fallback)
contains          = true
exists / forall   = false / true
Option.when       = Some(yes) None
zip               = Some((1,a)) None
flatMap list      = List(8080, 9090)
traverse ok       = Some(List(1, 2))
traverse bad      = None
parsePort ok      = Right(8080)
parsePort missing = Left(PORT is not set)
parsePort bad     = Left(PORT is not a number: eighty)
toJava / toScala  = Optional[j] / None
None.get          = Failure(java.util.NoSuchElementException: None.get)

Failure modes

  • Some(null) at a Java boundary. The Option is non-empty and the first map throws. Use Option(x).
  • .get everywhere. It reintroduces the exception Option exists to remove, now as NoSuchElementException.
  • Silent drops. flatMap or flatten over a list of parses discards bad inputs without any record.
  • Lost reasons. A chain of lookups returns None and nobody can tell which step failed; convert to Either earlier.
  • Nested Options. map with an Option-returning function produces Option[Option[A]]; use flatMap.
  • exists versus forall on None. Validation written with forall passes when the value is absent.
  • fold(Nil) and fold(None) inference errors; give the empty case its full type.

What to do next

  1. Search your codebase for Some( applied to values from Java APIs and replace it with Option(.
  2. Ban .get on Option outside tests with a Scalafix or Wartremover rule.
  3. Rewrite nested pattern matches over several Options as a for-comprehension.
  4. Review every flatMap or flatten over parsed input and decide explicitly between dropping, with a count, and traverse.
  5. Find functions that return Option where callers need a reason, and change them to return Either.
  6. Check case classes for Option[Boolean], Option[List[A]] and Option[Option[A]], and replace them with clearer types.
  7. Read functional error handling in Scala to see how Option, Either and Try fit together across application layers.
Key takeaway: Option is a sealed type with two cases, Some and None, that makes absence part of the type. Use Option(x) at every boundary with Java, because Some(null) is non-empty. getOrElse, orElse and fold evaluate their defaults lazily. Use map for plain functions and flatMap or a for-comprehension for steps that may be absent; choose deliberately between flatten, which drops failures, and traverse, which is all or nothing. When the caller needs to know why a value is missing, convert with toRight and continue in Either.