Handling Errors functionally with Scala 3
Introduction
This is a companion blog my fifth Functional Justin YouTube video which you can find here: https://youtu.be/wNVQ75KM8-4
If you're new to the series I'm exploring Scala 3 and functional programming using a simple expression evaluator, and adding features to it every week. Most of the videos are coffee break sized (10-15 minutes) but this one took a bit longer as I needed more time to explain the concepts. Next time will probably be back down to the more bite-sized format.
In video 1, https://youtu.be/J01u_Dmrx5U, I showed how you can use
Scala 3 features like Context Functions to pass context around. The
eval function below is an expression evaluator that takes expressions
of type Exp
, returns a result of type Int
and has an implicit
environment Env
which is a symbol table of values.
type WithEnv = Env ?=> Int def eval(exp: Exp): WithEnv = exp match case Var(id) => handleVar(id) case Val(value) => value case Add(l,r) => handleAdd(l,r) def handleVar(s: String): WithEnv = summon[Env].get(s).get
This is a nice example of context functions, but not so good an example of a pure functional program, let's see why.
Purity
What is a pure function? In short it has three properties… it is a total function, it is deterministic and it has no side effects.
A total function
has an answer (of a fixed type) for everything. Our
expression evaluator is a total function because every expression you
put in can be evaluated and returns a value. Now let's say we had a
divide function, and you can pass in a divisor that is zero and the
answer is infinity. That is not representable by Int so the function
is not total. We can only throw an error at this point.
By deterministic
, we mean that the evaluator gives the same answer for
every input expression, which may seem self-evident, but imagine if we
had a random number command. When used it would return different
answers every time and the program would not be deterministic.
Finally, by no side effects
, we mean the program does nothing
impure. It is not going to print to the screen, send an email, or
throw errors.
Is the expression evaluator code pure?
If your nose wrinkled when you saw this code above
summon[Env].get(s).get
then you're probably an experienced Scala
programmer who knows that you should not call get on an option.
What's happening there is a symbol table look up. First I 'summon' the
symbol table (see video 1 to understand context functions and where
the symbol table is coming from), then I look up the symbol using
get
. This returns an Option
because the symbol may be missing!
I hopefully pointed out at the time that you shouldn't call get
on
an Option in serious code because it will throw an error. That means
that as a program that can throw errors, our program is not pure.
Handling errors with Either
Since the 90s the Haskell folk have been dealing with impurity by wrapping it up in data types that describe the effects, and manipulating them with type classes. If that sounds hopelessly abstract, then fear not, in practise it's quite simple and we will fix our problems with a few lines of code.
Firstly let's look at what we mean by a data type… it is usually a higher kinded type that "contains" things of another type that you can define at compile time.
Scala's Either is a great example of a data type. It encodes the concept of errors. Our pure code does not deal with errors but we can still encode errors by wrapping them as follows.
val e1: Either[String, Int] = Right(10) val e2: Either[String, Int] = Left("Oops")
By encoding our values like this we can represent a computation that
has succeeded as a Right
value, and a computation that has failed
with some error as a Left
value.
What this means is we can no longer apply pure functions these values
directly. That is sort of the point. What we wanted to do was isolate
pure functions from having to deal with errors at all. So how do we
operate on Eithers? Well you are probably familiar with the map
function, and that can be used to apply a pure function to an either!
val e3 = e1.map(a => a + 1) // Right(11) val e4 = e2.map(a => a + 1) // Left("Oops")
Categorically Speaking
You may not really think of it as Category Theory, but whenever you map an Either you are using Functors!
The Haskell documentation is a nice place to learn about Functors. If you think of a normal pure function as a mapping of values from A to B, a Functor can map values that have been embellished, or wrapped in some special data type.
https://wiki.haskell.org/Functor
Helpfully, the kind folks behind Scala 3 have added how to implement type classes to their documentation. We can use that a starting point to build our own Functor and then make an instance that works with Eithers.
trait Functor[F[_]]: extension [A, B](x: F[A]) def ffmap(f: A => B): F[B]
This is all we need to define a Functor type class that can extend supported types with a map function. Note that I've added an f to differentiate the function from the built in map. Then I added another f by mistake, don't tell anyone, they might not notice!
Before we can use this against an Either we need to implement an
instance of the typeclass. Remember that Functor needs a type of kind
F[_]
. It has one "type hole". Either has two, which is not going to
work, so let's start by specialising to Either with only a fixed error
type of String.
First we make a type alias that reduces the Either to one unknown
type, the computation result type A
.
Next we provide an implementation of ffmap that does the work of mapping our pure function over an Either.
Note that this is roughly the same as the pure function. Instead of A
=> B
we are mapping F[A] => F[B]
where F is the Either.
type StringEither[A] = Either[String, A] given Functor[StringEither] with extension [A, B](x: StringEither[A]) def ffmap(f: A => B): StringEither[B] = { x match { case Right(a) => Right(f(a)) case Left(err) => Left(err) } }
Let's try it out.
val e1: Either[String, Int] = Right(10) val e2: Either[String, Int] = Left("Oops") val e3 = e1.ffmap(a => a + 1) // Right(11) val e4 = e2.ffmap(a => a + 1) // Left("Oops")
We can now apply pure functions to Eithers with String error
types. Where we want to get to is to be able to apply pure functions
to Either[Error,Numeric[A]]
so we're not quite there yet.
The first problem is that we can't handle the Error type that I want
to use in my expression evaluator, we can only handle String. Well we
can just make another instance of Functor for Either[Error,A]
?
Well, yes we could, but how about we make a generic instance of
Functor for all
Eithers?
To do that we need to use type lambdas. These were available in Scala 2 but are greatly simplified in Scala 3.
https://dotty.epfl.ch/docs/reference/new-types/type-lambdas.html
Here's the new instance for Functor with some notable changes.
1: given eitherFunctor[E]: Functor[[A] =>> Either[E, A]] with 2: extension [A, B](x: Either[E,A]) 3: def ffmap(f: A => B): Either[E,B] = { 4: x match { 5: case Right(a) => Right(f(a)) 6: case Left(err) => Left(err) 7: } 8: }
Line 1 is where the action is. First note that we named the given
instance eitherFunctor
. Our previous instance had no name. You can
leave the name out, but it's not recommended, especially for
libraries, since it makes the code easier to work with. See also that
the instance itself takes parameter E
which will represent our error
type.
Next the instance of Functor is for the type [A] =>> Either[E, A]
which is our type lambda. It means please give me a type that has a
single parameter A but that will be substituted into the Either[E,A]
in a way that is similar to how parameters are substituted into a
lambda function.
val e1: Either[String, Int] = Right(10) val e2: Either[Int, Int] = Left(1) val e3 = e1.ffmap(a => a + 1) // Right(11) val e4 = e2.ffmap(a => a + 1) // Left(1)
Now we can map over any type of Either! As you can see in the first case the pure function mapped over the A. In the second case the pure function was not executed and the error value is simply passed along.
Functor Laws
Next time we'll look at the Functor laws and show that our code obeys them.
Wrap up
I hope you enjoyed this blog and/or video. Please share, like or subscribe and help me spread this content to those that may find it useful.
For further info
Source code
https://github.com/justinhj/evalexample
Read more
https://dotty.epfl.ch/docs/reference/new-types/type-lambdas.html https://dotty.epfl.ch/docs/reference/contextual/extension-methods.html https://dotty.epfl.ch/docs/reference/contextual/type-classes.html
© 2020 Justin Heyes-Jones. All Rights Reserved.