In my previous article I talked about Monads: what they are and why we use them. I explained that whilst we can
write generic code capable of mapping any combination of Futures
, Options
, Lists
etc. we can’t necessarily do this when
usingflatMap
or flatten
. To write generic code we need to use something called a monad transformer.
You can find complete examples of the concepts discussed on my blog in my Github repo . In particular, check out the monad transfomers example
What is a Monad transformer?
In simple terms a monad transformer is a piece of code that understands how to handle a specific monad e.g. Option
.
It knows how to transform this monad into a different shape
Why do we need Monad transformers?
Lets look at an example. This code fetches users and orders from a database. Both calls return Options and we can easily
sequence the calls using the usual flatMap
/ for comprehension.
def fetchUser(id: Int): Option[User] = ???
def fetchOrder(user: User): Option[Order] = ???
for {
user <- fetchUser(1)
order <- fetchOrder(user)
} yield (user, order)
We can use flatMap
because we only have one type of Monad in play but what happens if these functions return Future[Option[A]]]
def fetchUser(id: Int): Future[Option[User]] = ???
def fetchOrder(user: User): Future[Option[Order]] = ???
for {
user <- fetchUser(1)
order <- fetchOrder(user) // doesn't compile
} yield (user, order)
We have nested Monads so we need something like:
val userAndOrder: Future[Option[(User, Order)]] = for {
maybeUser <- fetchUser(1)
maybeOrder <- maybeUser.map(fetchOrder).getOrElse(Future.successful(None))
} yield for {
user <- maybeUser
order <- maybeOrder
} yield (user, order)
It’s pretty messy but Cats comes to the rescue with its OptionT monad transformer:
import cats.data.OptionT
import cats.instances.future._
...
val eventualUserAndOrder = (for {
user <- OptionT(fetchUser(1))
order <- OptionT(fetchOrder(user))
} yield (user, order)).value
It’s much nicer and saves us writing the boilerplate code ourselves. Note that we need the final .value
call
because we’re actually flatMapping OptionT
not Future[Option[A]]
. The value
call simply returns the underlying type
OptionT
Let’s take a look at OptionT’s type parameters: OptionT[F[_], A]
. It’s pretty simple, we need to specify
the effect wrapping the option (the other monad i.e. Future in our previous example) and the inner type
(User or Order in the previous example). In many cases the compiler can infer the types but sometimes it can’t and we
need to help it.
Once we have an OptionT transformer we can perform the usual map
flatMap
and flatten
operations that we’re used to.
It’s worth remembering that although the example given dealt with Future[Option[A]]
it could just as easily have
been List[Option[A]]
or another valid outer monad
EitherT
Scala 2.12.x has a right biased either type and Cats includes type classes to “pimp” a 2.11.x Either and make it right
biased. Right biased simply means we can call map
and flatMap
on the either itself instead of having to select the
left or right projection. Being right biased means Either is also a Monad.
As with Option we probably want to wrap Either inside another monad:
def fetchUser(id: Int): Future[Either[ServiceError, User]] = ???
Of course cats includes a Monad transformer for Either and not surprisingly it’s
called EitherT: EitherT[F[_], A, B]
The syntax should look familiar, A
and B
can be thought of as “left type”
and “right type” and it’s usage is very similar to OptionT
Variance
Let’s look at a simple example:
import cats.data.EitherT
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import cats.instances.future._
case class User(id: Int)
case class Order(price: Double)
sealed trait ServiceError
case class UserNotFound(userId: Int) extends ServiceError
case class OrderNotFound(user: User) extends ServiceError
def fetchUser(id: Int): Future[Either[UserNotFound, User]] = ???
def fetchOrder(user: User): Future[Either[OrderNotFound, Order]] = ???
for {
user <- EitherT(fetchUser(1))
order <- EitherT(fetchOrder(user))
} yield (user, order)
Everything looks fine but when we try to compile this we’ll hit an error:
Error:(19, 12) type mismatch;
found : example.Hello.User => cats.data.EitherT[scala.concurrent.Future,example.Hello.OrderNotFound,(example.Hello.User, example.Hello.Order)]
required: example.Hello.User => cats.data.EitherT[scala.concurrent.Future,Product with Serializable with example.Hello.ServiceError,(example.Hello.User, example.Hello.Order)]
user <- EitherT(fetchUser(1))
The error may be a bit intimidating but I’ll highlight the relevant parts:
found: EitherT[OrderNotFound, ...]
required: EitherT[ServiceError, ...]
The problem is that we’re trying to sequence eithers with two different left types, UserNotFound
and OrderNotFound
.
We know that they are both ServiceErrors
but in this case Cats is invariant of types. As far as Cats is concerned
UserNotFound
and OrderNotFound
are unrelated.
We can fix this easily though by telling the compiler to treat the UserNotFound
and OrderNotFound
as ServiceErrors
for {
user <- EitherT(fetchUser(1): Future[Either[ServiceError, User]])
order <- EitherT(fetchOrder(user): Future[Either[ServiceError, Order]])
} yield (user, order)
Alternatively we could of course change the signature of the fetchUser
and fetchOrder
methods
What next?
Lean about MonadError which lets us add error handling to Monad stacks …