FEH: Option (Scala 3 Video)
Introduction
The first functional error-handling type I like to look at is Option
, and its companions, Some
and None
. I do this because (a) its similar to Java’s Optional
type, and (b) it’s a little more basic than Try
and Either
.
In a world where things can go wrong ...
Getting back to our Word Count application, our boss just came to us and told us we’re not getting a String
that represents the document, we’re getting a String
that is the name of the file that contains the document.
As any experienced developer knows, when you write certain functions, things can go wrong. This is especially true when you start working with File I/O, Network I/O, and Database I/O. In this world, files can be unreadable, networks can be down, servers can be down, etc., and these things must be accounted for.
When you write these functions in other programming languages you might throw exceptions or return null values, but in Scala we don’t do those things. As mentioned, we use error-handling data types.
As I wrote in this blog post many years ago, Tony Hoare, the inventor of the
null
reference — way back in 1965 — refers to the creation of thenull
value as his “billion dollar mistake.”
A simpler problem: Converting a String to an Int
I’m going to put off the Word Count application just a little while longer, and instead I want to work on a slightly easier problem. I’ll show how to safely convert a String
to an Int
. This is a relatively simple problem, so we can focus on our error-handling types rather than missing files or servers that are down. (We’ll deal with those later in the book.)
So the problem with converting a String
to an Int
is that sometimes it works, and sometimes it doesn’t. And when it doesn’t, the process throws an exception. The Scala REPL shows how this process succeeds and how it fails:
scala> "1".toInt
val res0: Int = 1
scala> "one".toInt
java.lang.NumberFormatException: For input string: "one"
In the first example the string properly represents an integer — "1"
— so the conversion process works. But when the string can’t be converted to an integer in the second example, the process blows up with a NumberFormatException
.
Code blowing up is bad. You can’t write algebra when your equations can explode.
A first attempt
If we didn’t have error-handling types, we might first try to write a makeInt
function like this:
def makeInt(s: String): Int =
try
s.toInt
catch
case e: NumberFormatException => 0
That function always returns an Int
, but the problem is that it isn’t accurate in all instances. For example, all of these function calls will return 0
, which is incorrect:
makeInt("0")
makeInt("")
makeInt("one")
In the first example the value returned is correct, but in the second and third examples, 0
is not the correct answer. So, what is the correct solution?
The correct solution
This is where Scala’s error-handling data types come in. Instead of the function returning an Int
, the correct approach is for it to return a data type that encapsulates the possibility of success or failure. As mentioned, I want to first work with the Option
type, and its sub-types, Some
and None.
The general solution is to (a) define your function to return an Option
that wraps the type that your function yields when it succeeds — Int
, in this case. And then in the body of the function, you return (b) a Some
for the success case, and (c) a None
for the failure case:
def makeInt(s: String): Option[Int] =
try
Some(s.toInt)
catch
case e: NumberFormatException => None
As shown, the Some
wraps the data type that you were able to calculate successfully, and in the error case you return a None
. (The None
type doesn’t contain anything, it’s just an indicator to callers of your function that something went wrong.)
When you call the function, you’ll see the Some
and None
types as its return value:
// success cases
makeInt("1") // Some(1)
makeInt("2") // Some(2)
// failure cases
makeInt("") // None
makeInt("one") // None
A key to this solution is that Option
is the base type, and Some
and None
are its sub-types:
┌────────┐
│ Option │
└───┬────┘
│
┌─────┴──────┐
│ │
┌────┴───┐ ┌────┴───┐
│ Some │ │ None │
└────────┘ └────────┘
So you declare that the function returns the parent type: Option
, or specifically an Option[Int]
in this case. Then the body of the function returns its sub-types: Some
for the success case, and None
for the error/failure case.
When you write the function, that’s all you have to do.
Using the function
Now that makeInt
is written like this, the flip side of the coin is, “As a caller of this function, how do I handle this type?”
pattern matching
One of the most common ways to handle an Option
is with pattern matching in a match
expression:
makeInt(aString) match
case Some(i) => println(s"Conversion worked! i = $i")
case None => println("The conversion failed.")
As shown, the match
expression lets you handle the two possible cases, Some
and None
. When you work with the Some
you extract the Int
out of it, and when you work with the None
, it’s just an indicator that something failed.
for expressions
A second common way the function is used is inside a for
expression, especially when you have multiple calls to your function. This code shows the result when three strings can be converted to integers:
val maybeSum: Option[Int] =
for
i <- makeInt("1")
j <- makeInt("2")
k <- makeInt("3")
yield
i + j + k
// result: maybeSum == Some(6)
Some developers like to use the word “maybe” when receiving an error-handling type, because value “may be” a success type, or not. So I thought I’d show that term here. The important part is that when the for
expression finishes, maybeSum
has the value Some(6)
.
Next, this exact same code shows what happens when one or more string values cannot be converted to integers:
val maybeSum: Option[Int] =
for
i <- makeInt("1")
j <- makeInt("Hi mom!")
k <- makeInt("3")
yield
i + j + k
// result: maybeSum == None
As shown, in this situation, maybeSum
is a None
rather than a Some
. However, notice that you don’t care at this point if something failed, you just write the code as shown, and it handles both the success and failure cases.
Then, whenever you want to work with maybeSum
’s value, you can work with in a match
expression:
maybeSum match
case Some(i) => println(s"Conversion worked! i = $i")
case None => println("The conversion failed.")
At some point in history, someone referred to the Some
case as the Happy Path, because it’s the success case. And that would make the None
case the Unhappy Path, because it’s the failure case.
But the keys here are:
- Your code is still algebra because you’re dealing with values, and not exceptions. (There is no short-circuiting or explosions.)
- Your code doesn’t get all uglified and hard to read with a bunch of
try
/catch
blocks.
Pure functions cannot lie
Before we move on, it’s worth repeating this about the signatures of pure functions: they cannot lie.
As you saw in this example, the makeInt
function screams loud and clear, “I’ll give you an Int
if everything goes well, but be warned that something can wrong here”:
def makeInt(s: String): Option[Int] = ???
-----------
Whenever you see Option
as the return type of a pure function, you know that something can go wrong inside that function.
makeInt can be more concise
For those who know Scala, I want to note that yes, makeInt
can be written more concisely. But for the purpose of these lessons, I’m using this longer form. (I’ll show the shorter form later.)
Update: All of my new videos are now on
LearnScala.dev