Toby Hobson

Stacking Monad Transformers

banner.png
Stacking Monad Transformers
Estimated reading time: 5 minutes

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.

There are better ways of handling nested monads. One is to use something called the “MTL” pattern which I will describe in later posts. The other is to use an ZIO, a brilliant library which encompasses the concept of a result, potential error and an “environment”. I will also cover ZIO in subsequent posts

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.

comments powered by Disqus

Need help with your project?

Do you need some help or guidance with your project? Reach out to me (email is best)