Daniel Ciocîrlan
9 min read •
Share on:
If you’re just getting started with Scala and Options seem hard to understand, this article is for you.
We’ll introduce Scala Options, a seemingly contrived data structure, but we’ll see in this post that it’s extremely powerful and useful in its simplicity.
We teach Options in-depth in the Scala Essentials course, along with examples and exercises, if you want to check it out. In this article, we’ll introduce Options, show you how they work, and why they exist.
The first real data structure we learn when getting started with Scala is the list. It’s quick to set up, and easy to understand.
val aList: List[Int] = List(1,2,3)
You probably know by now that lists are immutable — every transformation on a list results in a new list — and there are some transformations we use a lot: map
, flatMap
and filter
:
val aTransformedList = aList.map(x => x + 1) // [2,3,4]
val aTransformedList_v2 = aList.flatMap(x => List(x, x+1)) // [1,2 ,2,3, 3,4]
val aFilteredList = aList.filter(x => x % 2 == 0) // [2]
To recap:
map
turns a list into another, where for every element in the original list, the supplied function is applyflatMap
runs a function on every element on the list, resulting in many mini-lists; these lists are then concatenated into the final resultfilter
keeps the elements which satisfy the boolean predicate passed as argumentBecause we have map
and flatMap
, we can run for-comprehensions on lists:
val combinedLists = for {
num <- List(1,2,3)
char <- List('a','b')
} yield s"$num-$char"
// List(1,2,3).flatMap(num => List('a','b').map(char => s"$num-$char"))
// [1-a, 1-b, 2-a, 2-b, 3-a, 3-b]
You also probably know that for-comprehensions are rewritten by the compiler into chains of map
and flatMap
, as you can see in the above snippet. The coolest thing about for-comprehensions is that any data structure can be eligible for for-comprehensions, as long as they have map
and flatMap
with similar signatures as that of the List
type.
Why did we start this post describing lists instead of Options?
The easiest way to understand a Scala Option is to imagine it as a list with at most one value, that is, it either contains a single value, or nothing at all.
val anOption: Option[Int] = Option(42) // an option that contains a single value: 42
val anEmptyOption: Option[Int] = Option.empty // an option that doesn't contain any value
Let’s see some operators on Options.
First, let’s talk about the possible cases of an Option:
We can build these cases explicitly:
val anOption_v2: Option[Int] = Some(42) // same as Option(42)
val anEmptyOption_v2: Option[Int] = None // same as Option.empty
A very interesting case is when we use Option(null)
, which gives back a None
, i.e. an empty Option. We’ll come back to this idea, because it leads to the main reason why Scala Options are useful in the first place.
Much like lists, Options can be transformed with map
, flatMap
and filter
. The map
function transforms an Option by returning a new Option containing
val aTransformedOption: Option[Int] = anOption.map(x => x * 10) // Some(420)
The flatMap
method is a bit more difficult, but it resembles the list flatMap
method — the function we pass as argument turns an element into another Option:
val aTransformedOption_v2: Option[Int] = anOption.flatMap(x => Option(x * 10)) // Some(420)
However, in this case, the flatMap
method doesn’t have the meaning of concatenation (as was the case for lists), because here there’s either a single value, or nothing. For Option, the flatMap
method is a way of chaining computations that may either produce a value or nothing. This general idea of sequencing computations is immensely powerful.
Finally, the filter
method retains the original value of the Option if it passes a predicate, or it discards it otherwise, turning the original Option into None. Of course, if the original Option was empty to begin with, there’s nothing to filter, hence nothing to process.
val aFilteredOption: Option[Int] = anOption.filter(x => x > 100) // None
Now, because we have map
and flatMap
, we also have for-comprehensions unlocked for Option:
val combinedOptions = for {
num <- Option(42)
str <- Option("Scala")
} yield s"$str is the meaning of life, aka $num"
For those of you coming to Scala from another language, the for-comprehension might look a little… unusual. When our background is mostly imperative languages (Java, Python, C++, C#, etc), we tend to think about for
structures as “loops”. In Scala, a for-comprehension is not a loop, but rather a chain of map
and flatMap
, which we saw are expressions returning other data structures.
Besides map
, flatMap
and filter
, Options also have other APIs to transform values or run checks:
val checkEmpty: Boolean = anOption.isEmpty
val innerValue: Int = anOption.getOrElse(99)
val aChainedOption: Option[Int] = anEmptyOption.orElse(anOption)
The isEmpty
method checks whether an Option contains nothing. The getOrElse
method returns either the value inside the option, or the default value which we pass as argument. Finally, the orElse
method returns the original Option if non-empty, or the other option otherwise. There’s also a get
method on Option, which returns the value inside, but beware: if the Option is empty, the method will crash!
There are more methods in the Option data type, but these are the most frequently used.
At this point, you might be thinking why on Earth we need this weird data structure which resembles a list, but it only contains a single value or nothing. Why can’t we just use a list?
The reason why Options are useful is not the same why Lists are useful. Options are not used to “store” values — as your intuition clearly can tell, we can use lists to store as many values as we like. The utility of Options is not in data storage.
Options are used to describe the possible absence of a value.
If, like most of us, your experience is mostly with the “mainstream” languages like Java or C, you’ve probably been dealing with the absence of values by using the null
reference. Because null
is a proper replacement for every type, at the slightest suspicion that an expression might return null, we need to write defensive code so that we stay away from the dreaded NullPointerException
.
def unsafeMethod(arg: Int): String = null // implementation not important, assume that for some code path the function returns null
// defensive code
val potentialValue = unsafeMethod(44)
val myRealResult =
if (potentialValue == null)
"ERROR"
else
potentialValue.toUpperCase()
However, in real life we deal not just with one value, but with many. Assume we want to combine two such calls to unsafeMethod
. How would we do it? Defensively, of course:
val callToExternalService = unsafeMethod(55)
val combinedResult =
if (potentialValue == null)
if (callToExternalService == null)
"ERROR 1"
else
"ERROR 2"
else
if (callToExternalService == null)
"ERROR 3"
else
potentialValue.toUpperCase() + callToExternalService.toUpperCase()
Nothing short of horrible. What if we need to combine 3 values? Do we add 8 if-clauses there? This is unproductive, unmaintainable and unreadable.
Options can save us from ourselves:
val betterCombinedResult: Option[String] = for {
firstValue <- Option(unsafeMethod(44))
secondValue <- Option(unsafeMethod(55))
} yield firstValue.toUpperCase() + secondValue.toUpperCase()
val finalValue = betterCombinedResult.getOrElse("ERROR")
The for-comprehension, because it’s expressed in terms of map
and flatMap
, can take care automatically to check whether the Options inside are empty or not, and return the appropriate value, without us checking everything ourselves. This code offers many benefits:
With this in mind, we’ll write a first piece of good practice:
When you have APIs that risk returning nulls, wrap them in Options when you use them. Better yet, if you can change the API, return Option[YourType]
instead.
Beginning Scala programmers, upon learning about Options, can successfully navigate Option transformations in other people’s code. However, when some external code hands them an Option, the immediate itch is to try to “get” the value inside the Option, so that you can do something meaningful to it.
That’s a mistake.
Because you don’t know whether an Option is empty or not, if we ever want to “get” a value safely we need to test whether the Option is empty or not. Considering the same example with calling the unsafeMethod
multiple times (this time wrapped inside Options), scratching the itch to “get” the values inside will lead us to code that looks like this:
val firstValue = Option(unsafeMethod(44))
val secondValue = Option(unsafeMethod(55))
val combinedResult_v2 =
if (firstValue.isEmpty)
if (secondValue.isEmpty)
"ERROR 1"
else
"ERROR 2"
else
if (secondValue.isEmpty)
"ERROR 3"
else
firstValue.get.toUpperCase() + secondValue.get.toUpperCase()
Notice something? This is just as bad as dealing with nulls in the first place!
So here’s the second piece of advice:
Resist the temptation to “get” the values inside your Options. Rather, use transformations like map
, flatMap
, filter
, orElse
, etc.
We mentioned at the beginning that we can build Options with the Some
and None
constructors explicitly. That’s discouraged; you should always use the Option
constructor/apply method all the time.
The reason is that by the contract of Some
, this Option is guaranteed to never contain a null value. However, if you ever write
val myOption = Some(myExpression)
on the grounds that you’re “sure” the expression will never return null, you can be mistaken. If so, you’ll be gravely mistaken, because subsequent computations will rely on the fact that the Option is non-empty, or that the value contained is not null. If you ever break that contract, your application can crash so bad and so much later than the moment you wrote this code, that debugging can take hours, days or more.
So here’s the final bit of best practice:
Always use the Option
“constructor”/apply method to build new Options. NEVER EVER use Some(null)
or Some(anExpressionThatCanBeNull)
, because you’ll kill us all.
In this article, we introduced Scala Options: how you can start thinking about them, the main API, how to transform Options, why they are useful, and we also shared some bits of best practice when dealing with them.
Share on: