Implementing wc with Try (Scala 3 Video)
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 returnTry
- 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 exceptionSuccess
,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.
Update: All of my new videos are now on
LearnScala.dev