A lot of Scala developers I know regularly use Option to write concise code in situations where something may or may not be returned by a given function. By getting familiar with the functions available on the Option class, you can chain many operations together without a lot of boilerplate or defensive protection against nonexistent values. Something like:
getAnOption .flatMap(transformIntoAnotherOption) .map(changeThing) .filter(thingIsTheRightType)
Assuming in this contrived bit of pseudo-code that getAnOption and transformIntoAnotherOption are functions that return Option types, this chain of function calls will short-circuit if any call returns a None. For example, if transformIntoAnotherOption returns None, then the expression evaluation is complete without ever running changeThing.
But what if I want to know which part of a complex expression like the one above returned None? It's tricky and less elegant when using an Option expression which simply evaluates to Some or None.
Let's say I'm writing a database application that requires a two-phase lookup, either phase of which may fail to find the requested record. I want to be able to:
- Look up an id based on a user-provided String
- If we found an id, look up the Record
- If we found a Record, validate it, process it, display it in an app, etc
def databaseCall[A](value: A) = if (nextInt % 5 == 0) None else Some(value) def nextInt = math.abs(scala.util.Random.nextInt)
And two wrapper calls to represent our two phases of lookup:
def lookupId(key: String): Option[Int] = databaseCall(nextInt) def lookupRecord(id: Int): Option[Record] = databaseCall(Record(id))
Chaining Option calls is a natural fit if we just need to find a record and return it (or not):
def findRecordByKey(key: String): Option[Record] = databaseCall(key) .flatMap(databaseCall) .filter(validateIt) .map(processIt) .foreach(displayInApp)
The code above will sometimes call displayInApp with a value, and sometimes it will not. It is concise and avoids cluttering the logic with explicit conditionals and error handling. But it does not allow us to provide detailed feedback about what part of the process failed. Say we want to log whenever a databaseCall fails. We have to break up our nice single expression with calls to isDefined and error logging code. Not exactly elegant functional monadic wizardry that will get you invited to the cool kids' parties.
Either FTW
First thing, let's change our database functions so they return Either instead of Option. Conveniently, Option has the functions toLeft and toRight for just this purpose. We'll use toRight in our example, which returns a Right containing the value of the Option it is called on if it is a Some. toRight() takes an argument that will be used to construct a Left if the Option is None. In our case, we'll provide a straightforward error message to use in case of failures:
def lookupId(key: String): Either[String, Int] = databaseCall(nextInt).toRight("failed to find id based on key") def lookupRecord(id: Int): Either[String, Record] = databaseCall(Record(id)).toRight("failed to find record based on id")
Easy enough. Here's how we can chain the calls to these functions:
lookupId("key").right .flatMap(lookupRecord(_))Pretty similar to how we use Options. Calling right() on the result of lookupId("key") projects the Either as a Right. The contained value is the passed to lookupRecord and so on. If the Either is a Left, lookupRecord is never called. So just as with Option, we can chain our calls, evaluating to an Either in the end.
So we end up with either a Right(someRecordInstance) or a Left(someErrorMessage). We can use pattern matching to process this result, or you can use the fold() function, which takes two function arguments. The first is run in the case of a Left, the second in the case of a Right. So that's just super for us. Here's the whole thing:
lookupId("key").right .flatMap(lookupRecord(_)).fold( s => println("ERROR - " + s), s => println("looked up record " + s))
Running this code a thousand or so times prints a bunch of lines like the following:
looked up record Record(2073762079) ERROR - failed to find id based on key ERROR - failed to find record based on id ERROR - failed to find record based on id looked up record Record(1377010886) looked up record Record(1665383097) looked up record Record(646489647)
We now know when we failed to resolve an id to a key. We know if the record itself was not found. And assuming all went well, we get our Record result returned, validated, processed and displayed in our app in one neat expression. And we get invited to the cool kids' parties.
Download a buildable version of this code from github, and enjoy using Either in your projects.
Thanks for this post.
ReplyDelete