home search about rss feed twitter ko-fi

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 the null 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