Implementing wc with Try

Okay, given that background on how we handle possible errors in Scala, let’s get back to our Word Count application. We left off at the point where our boss said to us, “Um, yeah, that wc function needs to handle a string that represents the filename that contains the document, not the document itself.”

The incorrect solution

To quickly re-state what I just wrote about functional error handling, recall that this is the incorrect solution, we do not write code like this in Scala/EOP:

@throws Exception
def wc(filename: String) ...

Exceptions short-circuit algebraic equations and blow up blueprints, so we don’t do that.

Working with a file

Having read the previous error-handling lessons, your algebraic-oriented brain should now be thinking that we need to change our previous function signature:

def wc(document: String): VectorMap[String, Int]

to something like this:

def wc(filename: String): Option[?]
def wc(filename: String): Try[?]
def wc(filename: String): Either[?, ?]

The basic idea is that files may not exist, or they may not be readable, so we use the error-handling types to account for those possibilities.

If we focus on just Try for a moment:

def wc(filename: String): Try[?]

an excellent question at this point is, “What goes inside that Try declaration?”

Try’s type

As we saw in the previous lessons, the type that goes inside Try[] is the success case data type. In this case — because the original wc function returns a VectorMap[String, Int] when it succeeds — our new function’s return type should look like this:

def wc(filename: String): Try[VectorMap[String, Int]]

To make that a little easier to see, here’s the success type, underlined:

def wc(filename: String): Try[ VectorMap[String, Int] ]
                               ----------------------

Option and Either

To be thorough with our error-handling options, Option is declared the same way:

def wc(filename: String): Option[ VectorMap[String, Int] ]
                                  ----------------------

And as you know, with Either you declare both the failure and success types. The failure type can be anything, but it’s usually an exception or a string, so its signature will typically be one of these:

def wc(filename: String): Either[ Throwable, VectorMap[String, Int] ]
def wc(filename: String): Either[ String,    VectorMap[String, Int] ]

I put extra spaces around those brackets to make things more obvious, but you usually don’t do that, so our new function that reads a document from a given filename will have signatures look like these:

def wc(filename: String): Try[VectorMap[String, Int]]
def wc(filename: String): Option[VectorMap[String, Int]]

def wc(filename: String): Either[Throwable, VectorMap[String, Int]]
def wc(filename: String): Either[String, VectorMap[String, Int]]

Building the new solution

Now let’s look at the body of our new file-reading function.

The Try solution

Again, if we focus only on the Try solution — which I prefer over Option for I/O-related functions, because we can see the exception when needed — I’ll start by renaming this new top-level function to wcFromFile:

def wcFromFile(filename: String): Try[VectorMap[String, Int]]

As you can imagine, the first thing you’ll do inside this function is to call a file-reading function. So far I know that I’ll give that function the filename, which is a String:

def readFile(filename: String)
             ----------------

And you also know two other things:

  • I just mentioned that I prefer to use Try for I/O functions, so this function should return Try
  • The function needs to return the document that it reads from that filename, and that document is a String

Those two items let us complete the function’s type signature:

def readFile(filename: String): Try[String]
                                -----------

Now that we know what readFile looks like, we add it as the first line to our new wcFromFile function:

def wcFromFile(filename: String): Try[VectorMap[String, Int]] =
    val maybeDocument: Try[String] = readFile(filename)
    ...

A verbose solution

So now maybeDocument either contains (a) a String version of the document, or (b) an exception. A verbose way to handle maybeDocument is the approach I showed earlier in the makeInt error-handling lessons, using a match expression:

def wcFromFile(filename: String): Try[VectorMap[String, Int]] =
    val maybeDocument: Try[String] = readFile(filename)
    maybeDocument match
        case Success(docAsString) => Success(wc(docAsString))   // [1]
        case Failure(e)           => Failure(e)                 // [2]

As shown in Note 1, in the Success case I pass the document (docAsString) to the old wc function. If you look back at that function, you’ll see that it has the type signature VectorMap[String, Int]. As you now know, this means that wc cannot fail. Because wcFromFile needs to return a Try type, I wrap this map in the Success type.

And then in the Failure case (Note 2), I return a new Failure instance that contains the exception that occurred when trying to read the file.

While this solution works, a problem with it is that I’m getting a Try from readFile, and then I unpack it into a Success or Failure, and then I re-pack it back into those types in the match expression. I can do better.

A more concise solution

The more concise solution is to use the map method that’s available on the Success and Failure types. The way map works is that if it’s called on:

  • Failure, it returns the exception
  • Success, map lets you operate on the success value however you want to

So again, the first step of the function body stays the same:

def wcFromFile(filename: String): Try[VectorMap[String, Int]] =
    val maybeDocument: Try[String] = readFile(filename)
    ...

After that, I replace the match expression with a map method call on maybeDocument, which is a Try. Inside map I pass the document to the wc function:

def wcFromFile(filename: String): Try[VectorMap[String, Int]] =
    val maybeDocument: Try[String] = readFile(filename)
    maybeDocument.map(doc => wc(doc))

The result of that expression has the type Try[VectorMap[String, Int]], which matches our function’s return type.

If you prefer not using intermediate variables, you can write this solution more concisely, like this:

def wcFromFile(filename: String): Try[VectorMap[String, Int]] =
    readFile(filename).map(doc => wc(doc))

or this, using Scala’s underscore shortcut in the anonymous function:

def wcFromFile(filename: String): Try[VectorMap[String, Int]] =
    readFile(filename).map(wc(_))

Whichever approach you prefer, the key here is that both Success and Failure implement the map method, and with Success, map operates on the success value — so it works with the wc call — and with Failure, map just returns the exception it holds.

The Either solution

Because Either is also a good solution for I/O functions, let’s see how it works. First, its file-reading function signature looks like this:

def readFile(filename: String): Either[Throwable, String]

Next, its top-level function looks like this:

def wcFromFile(filename: String): Either[Throwable, VectorMap[String, Int]]

And finally, the short version of its function body looks like this:

def wcFromFile(filename: String): Either[Throwable, VectorMap[String, Int]] =
    readFile(filename).map(wc(_))

Because of its flexibility, Either is a bit more verbose, but this signature:

Either[Throwable, VectorMap[String, Int]]

means that if something goes wrong, you’ll get an exception:

Either[Throwable, VectorMap[String, Int]]
       ---------

but if all goes well, you’ll get the sorted map that we’re really after:

Either[Throwable, VectorMap[String, Int]]
                  ---------------------

One last point about types

I showed this before, so I’ll just briefly touch on this again here. These function signatures:

def wcFromFile(filename: String): Try[VectorMap[String, Int]]
def wcFromFile(filename: String): Either[Throwable, VectorMap[String, Int]]

are probably easier to read if we use Scala 3’s opaque types to create more meaningful type names like these:

def wcFromFile(filename: String): 
    Try[MapSortedByValueDesc[Word, Count]]

def wcFromFile(filename: String): 
    Either[Throwable, MapSortedByValueDesc[Word, Count]]

And lastly, if you really want to have some fun — and make your code easier to read — you can create type aliases like these:

def wc(filename: String): WordCountMapTry
def wc(filename: String): WordCountMapEither

With type aliases and opaque types, solutions like these are limited only by what you think is best, and easiest to read and maintain.

Attribution

The “idea” icon in this video comes from this icons8.com link.