Monad transformers

Monad Transformers

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.

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 userAndOrder = (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