If you haven’t read the first article in the series I suggest you do so as this builds on the same example.
We ended the last article with the following code:
case class Person(name: String)
case class Customer(customerNumber: Int, p: Person)
type HttpRequest = String
type HttpResponse = String
type WebRequest = HttpRequest => HttpResponse
val serialiseCustomer: Customer => HttpResponse = ???
val deSerialisePerson: HttpRequest => Person = ???
def createCustomer(p: Person): Customer = {
// remove the saving logic
Customer(1, p)
}
def saveCustomer(c: Customer): Customer = {
// do DB stuff, we'll deal with failure in a later post
c
}
val registerCustomer: WebRequest =
deSerialisePerson andThen
createCustomer andThen
saveCustomer andThen
serialiseCustomer
Next we need to deal with failures e.g. The database down or invalid user input.
Let’s start with a new requirement:
A user must specify an email and we’re going to validate it at least has an @ sign in. Our beautiful composition now has to fork or end early in some way. Are we going to revert to imperative programming with if statements? Never!
One of the goals in functional Scala is to represent as much as possible in the type system. This is brilliant documentation to the next developer to touch the code. Let’s see one way to represent failure in the type system.
sealed trait Error
case object InvalidEmail extends Error
case object DatabaseDown extends Error
type HttpRequest = String
type OkHttpResponse = String
type WebRequest = HttpRequest => Either[Error, OkHttpResponse]
def validatePerson(p: Person): Either[Error, Person] =
if (p.email.contains("@"))
Right(p)
else
Left(InvalidEmail)
We’ve introduced an Either
type which does exactly that, it is Either an Error which we’ve defined as a sealed trait
and some case objects (think Enum in Java) OR it is a valid OkHttpResponse
.
This was another aha moment for me. Now the function type tells me exactly what to expect.
Either an Error that is a InvalidEmail
or DatabaseDown
or we get HttpResponse.
An either is implemented as two case classes, Left and Right. Left is by convention for errors and Right is for the happy path.
I’ve renamed HttpResponse
to OkHttpresonse
as of course we’d still send a HttpResponse to the user it would just be
a 4xx or a 5xx.
To do something similar in Java you could use checked exceptions. But the verbosity of handling them at every layer usually results in the use of runtime exceptions. Runtime exceptions are completely hidden, they circumvent the type system and make reasoning about code very hard.
But will our new Either type cause the same issues as checked exceptions? Let’s see!
The problem with our validatePerson
function is it no longer composes with the next function createPerson
. The
output of validatePerson
is a Either[Error, Person]
but the input of createPerson
is Person
.
No no no. In functional programming we can lift
functions. Sounds scary but all it means is
we can take a function with type: Cat => Dog
and turn it into a function of type F[Cat] => F[Dog]
where
F
is something like either.
There are libraries out there that do this for you but we’ll write our own and the final post in this series will use the libraries I’d recommend in production code.
object Lifting {
def lift[A, B, Error](f: A => B): Either[Error, A] => Either[Error, B] = e => e.map(f)
}
Map is a function that transforms the A in either to B if it is a Right
and just propagates the error if it is a
Left
.
Pause and think about that. If we map a Either[DeadAnimal, Cat]
to a Either[DeadAnimal, Dog]
and the input is a
Left(DeadAnimal)
then we just get our dead animal back. We essentially do nothing as soon as we get our first Left
.
However if our cat is still alive and we pass in a Right(Cat)
then we’ll magically turn our cat into a dog and get a
Right(Dog)
. How you actually convert cats into dogs I’ll leave to a scientist.
Enough about cats and dogs. We’ll instead use this to take our createCustomer: Person => Customer
function and turn it into createCustomer: Either[Error, Person]
=> [Error, Customer]
And we can do the same for saveCustomer
and serialiseCustomer
. Here is the full code now:
object Errors {
case class Person(name: String, email: String)
case class Customer(customerNumber: Int, p: Person)
sealed trait Error
case object InvalidEmail extends Error
case object DatabaseDown extends Error
type HttpRequest = String
type OkHttpResponse = String
type WebRequest = HttpRequest => Either[Error, OkHttpResponse]
def validatePerson(p: Person): Either[Error, Person] =
if (p.email.contains("@"))
Right(p)
else
Left(InvalidEmail)
val serialiseCustomer: Customer => OkHttpResponse = ???
val deSerialisePerson: HttpRequest => Person = ???
def createCustomer(p: Person): Customer = {
// remove the saving logic
Customer(1, p)
}
def saveCustomer(c: Customer): Customer = {
// do DB stuff, we'll deal with failure in a later post
c
}
import Lifting._
val registerCustomer: WebRequest =
deSerialisePerson andThen
validatePerson andThen
lift(createCustomer) andThen
lift(saveCustomer) andThen
lift(serialiseCustomer)
}
object Lifting {
def lift[A, B, Error](f: A => B): Either[Error, A] => Either[Error, B] = e => e.map(f)
}
Our new function composition lifts all the functions that come after the first function that could fail:
val registerCustomer: WebRequest =
deSerialisePerson andThen
validatePerson andThen
lift(createCustomer) andThen
lift(saveCustomer) andThen
lift(serialiseCustomer)
What’s so cool about this is that as soon as the first function fails the rest of the functions are never invoked because of the
way the map
in our lift
function works.
We’ll change the type of saveCustomer from Customer => Customer
to Customer => Either[Error, Customer]
because we
all know talking to the database can fail.
This again breaks our composition and lift doesn’t work on a function that returns an Either so we need a slightly different lift:
object Lifting {
def lift[A, B, Error](f: A => B): Either[Error, A] => Either[Error, B] = e => e.map(f)
def liftErrorFunction[A, B, Error](f: A => Either[Error, B]): Either[Error, A] => Either[Error, B] = e => e.flatMap(f)
}
This new function uses flatMap
to allow us to convert a function that takes a function of type A => Either[Error, B]
and returns a function of type: Either[Error, A] => Either[Error, B]
Now our composition works again:
val registerCustomer: WebRequest =
deSerialisePerson andThen
validatePerson andThen
lift(createCustomer) andThen
liftErrorFunction(saveCustomer) andThen
lift(serialiseCustomer)
Now we deal with all the errors and we don’t have our code filled with try catch but our types still represent exactly what is happening under the covers.
All the intent is there without boiler plate!
This is such a common pattern Scala’s for comprehension does this for us and we can delete our lift functions:
val registerCustomerMkTwo: WebRequest = in => {
val p = deSerialisePerson(in)
for {
person <- validatePerson(p)
customer = createCustomer(person)
saved <- saveCustomer(customer)
} yield serialiseCustomer(saved)
}
Or you can inline the lift
and liftErrorFunction
and call map
and flatMap
directly.
This style will take some getting used to but in my opinion it is far preferable to using checked exceptions and shows intent far better than hidden runtime exceptions. For a tiny bit more effort, e.g. the lifting or using a for comprehension, our types tell us exactly what is going on.
Either is just one of the types that allow this. There is also:
Go and find these and re-do the above examples with them.