In my previous posts I discussed Monads and Monad transformers. Put simply a monad transformer allows us to wrap
one Monad in another e.g. a Future[Either[Error, Int]]
. What if we want to wrap more monads together e.g. a Future/Task,
Either, and Writer. Firstly why would we want to do this? Is it even a good idea?
You can find complete examples of the concepts discussed on my blog in my Github repo
In particular, check out the Stacking transformers example
Motivation
The basic principle of functional programming is that all operations, both effectful and pure should be described
by the input and output of functions. As good functional programmers we should look at a function signature and know
exactly what it does. A return type of Task[Either[Error, User]]
tells us that the function will return either an error
or a user, and the operation will be deferred until the Task is run (Monix task is Lazy). So far so good, but what about
logging? Can we reflect the fact that this function writes log entries in the return type?
Writer and Either
We can use a Writer monad to accumulate logs and do something with them “at the end of the world”. To use a Writer monad
along with Either and Task we somehow need to stack Task, Either and Writer together. We would ultimately return something like
Task[Writer[Vector[String], Either[Error, User]]]
. If you’re thinking this type is starting to look ridiculous bear with me.
In future posts I’ll explain how we can dramatically simplify this. At this stage I just want you to understand the basic
concepts and motivations.
A simple (but useful) example
Let’s take a look at a simple, but very useful example. We’ll build a program which fetches a user followed by an order, accumulative logs along the way. First some scaffolding:
package uk.co.tobyhobson.cats.transformers
import cats.data.{EitherT, WriterT}
import cats.implicits._
import monix.eval.Task
import scala.concurrent.Await
import scala.concurrent.duration._
object StackingMonadTransformers {
// Our error hierarchy
sealed trait ServiceError
case class UserNotFound(id: Int) extends ServiceError
case class OrderNotFound(user: User) extends ServiceError
case class User(id: Int)
case class Order(totalAmount: Int)
}
The import cats.implicits._
line simply imports everything from the cats ecosystem (syntaxes, instances etc). It’s a bit
of overkill but it keeps the code concise
Next we have the usual sealed trait hierarchy representing the error conditions and case classes representing users and orders.
Now the tricky part. We need to define a type alias representing our custom monad “stack”. We will be using the familiar EitherT along with WriterT:
type OurStack[A] = WriterT[EitherT[Task, ServiceError, *], Vector[String], A]
I will try to explain this as best I can. WriterT
is parameterized on the “F” (effect), log type and result
i.e. WriterT[F, L, V]
. In our case we are using a Vector of Strings to represent the “log” and A for the result.
The difficult bit is the F. In our case “F” is not a Future or Task, it’s an EitherT. We need to make EitherT
look like a real F, which is actually F[_]
i.e. a type constructor with one “hole”. So we use a little trick/hack
called the kind compiler plugin to allow us to create a custom type on the fly. It’s equivalent to:
type EitherStack[A] = EitherT[Task, ServiceError, A]
type OurStack[A] = WriterT[EitherStack, Vector[String], A]
We can now define our functions:
def fetchUser(id: Int): OurStack[User] = {
val eitherT = EitherT.rightT[Task, ServiceError](User(id))
// we can use WriterT's apply method to build a WriterT from an F[(L, V)]
// i.e. a tuple, where L is the log and V is the value
val withLogs = eitherT.map(user => Vector("getting user") -> user)
WriterT(withLogs)
}
def fetchOrder(user: User): OurStack[Order] = {
val eitherT = EitherT.rightT[Task, ServiceError](Order(100))
val withLogs = eitherT.map(user => Vector("getting order") -> user)
WriterT(withLogs)
}
We compose them as usual, using a for comprehension:
val program = for {
user <- fetchUser(1)
order <- fetchOrder(user)
} yield order
We now need to “run” the program to access the underlying monads:
val eventualLogs = program.written.value.runToFuture
val logs = Await.result(eventualLogs, 1.second)
val eventualResult = program.value.value.runToFuture
val result = Await.result(eventualResult, 1.second)
println(logs)
println(result)
We now have both the Error/Result and the associated log entries. Our final program looks like this:
package uk.co.tobyhobson.cats.transformers
import cats.data.{EitherT, WriterT}
import cats.implicits._
import monix.eval.Task
import scala.concurrent.Await
import scala.concurrent.duration._
object StackingMonadTransformers {
// Our error hierarchy
sealed trait ServiceError
case class UserNotFound(id: Int) extends ServiceError
case class OrderNotFound(user: User) extends ServiceError
case class User(id: Int)
case class Order(totalAmount: Int)
// Our custom monad stack
type OurStack[A] = WriterT[EitherT[Task, ServiceError, *], Vector[String], A]
def fetchUser(id: Int): OurStack[User] = {
val eitherT = EitherT.rightT[Task, ServiceError](User(id))
// we can use WriterT's apply method to build a WriterT from an F[(L, V)]
// i.e. a tuple, where L is the log and V is the value
val withLogs = eitherT.map(user => Vector("getting user") -> user)
WriterT(withLogs)
}
def fetchOrder(user: User): OurStack[Order] = {
val eitherT = EitherT.rightT[Task, ServiceError](Order(100))
val withLogs = eitherT.map(user => Vector("getting order") -> user)
WriterT(withLogs)
}
def main(args: Array[String]): Unit = {
import monix.execution.Scheduler.Implicits.global
val program = for {
user <- fetchUser(1)
order <- fetchOrder(user)
} yield order
val eventualLogs = program.written.value.runToFuture
val logs = Await.result(eventualLogs, 1.second)
val eventualResult = program.value.value.runToFuture
val result = Await.result(eventualResult, 1.second)
println(logs)
println(result)
}
}
When we run the program we see the following output:
Right(Vector(getting user, getting order))
Right(Order(100))
Summary
Although messy, we can stack monad transformers together, we just need to make one transformer look like an F[_]
and
feed it into another transformer.