47 Degrees joins forces with Xebia read more

FP for Beginners - II - ScalaZ Monad Transformers

FP for Beginners - II - ScalaZ Monad Transformers

Last month, in the first of our Beginners series, we introduced you to Validation, a useful construct to deal with error handling. This time we’re going to tackle Monad Transformers.

One issue we face with monads is that they don’t compose. This can cause your code to get really hairy when trying to combine structures like Future and Option. But there’s a simple solution, and we’re going to explain how you can use Monad Transformers to alleviate this problem.

For our purposes here, we’re going to utilize a monad that serves as a container that may hold a value and where a computation can be performed.

Given that both Future[A] and Option[A] would be examples of monads.

Because monads don’t compose, we may end up with nested structures such as Future[Option[Future[Option[A]]] when using Future and Option together. Using Monad Transformers can help us to reduce this boilerplate.

Future and Option

In the most basic of scenarios, we’ll only be dealing with one monad at a time making our lives nice and easy. However, it’s not uncommon to get into scenarios where some function calls will return Future[A], and others will return Option[A].

So let’s test this out with an example:

case class Country(code : Option[String])

case class Address(country : Option[Country])

case class Person(name : String, address : Option[Address])

Now we can define getCountryCode(maybePerson : Option[Person]) : Option[String]

def getCountryCode(maybePerson : Option[Person]) : Option[String] = {
    maybePerson «flatMap» { person =>
        person.address flatMap { address =>
           address.country flatMap { country =>
              country.code
           }
        }
    }
}
»Nested flatMap calls|Nested <code>flatMap</code> calls allow the nested options to flatten automatically into a single one«

Since Option is the monad that has defined map and flatMap, and these are the only values needed on a type to use in for comprehensions, we can further simplify this to:

def getCountryCode(maybePerson : Option[Person]) : Option[String] = for {
    «person <- maybePerson»
    address <- person.address
    country <- address.country
    code <- country.code
} yield code
»For comprehension bind|When in a for comprehension binding to the left gives us the value as evaluated if we were flatMapping in the right side«

Alright, a piece of cake right? That’s because we were dealing with a simple type Option. But here’s where things can get more complicated. Let’s introduce another monad in the middle of the computation. For example what happens when we need to load a person by id, then their address and country to obtain the country code from a remote service?

Now we’ve got two new functions in the mix that are going to call a remote service, and they return a Future. This is common in most APIs that handle loading asynchronously.

def findPerson(id : String) : Future[Option[Person]] = ???

def findCountry(addressId : String) : Future[Option[Country]] = ???

A naive implementation attempt to get to a country.code from a person.id might look something like this.

def getCountryCode(personId : String) = {
  findPerson(personId) map { maybePerson =>
    maybePerson map { person =>
      person.address map { address =>
        findCountry(address.addressId) map { maybeCountry =>
          maybeCountry map { country =>
            country.code
          }
        }
      }  
    }
  }
}

This isn’t actually what we want since the inferred return type is Future[Option[Option[Future[Option[Option[String]]]]]]. We can’t use flatMap in this case because the nested expression does not match the return type of the expression they’re contained within. This is because we’re not flatMapping properly over the nested types. When you use flapMap in the right places ending up with a Future[Option[String]] it would look like this:

def getCountryCode(personId : String): Future[Option[String]] = {
  findPerson(personId) flatMap { case «Some(Person(_, Some(address)))» =>
    findCountry(address.addressId) map { case Some(Country(code)) =>
        code
    }
  }
}
»Extractors|Extractors can help us reduce the type conversion boilerplate a ton«

That’s obviously a lot nicer, but we had to resort to cases and extractors to unveil the inner values of the nested Future[Option]. We’re also taking advantage of the fact that we’re utilizing case classes that implement the extractors automatically via unapply.

While this is a nicer situation, it’s still not ideal. The levels of nesting are pyramidal with flatMap and map and are as deep as the number of operations that you have to perform regardless of your ability to use extractors.

Let’s look at how a similar implementation would look like using for comprehensions:

def getCountryCode(personId : String): Future[Option[String]] = {
  for {
    maybePerson <- findPerson(personId)
    person <- Future.successful {
        «maybePerson getOrElse (throw new NoSuchElementException("..."))»
    }
    address <- Future.successful {
        person.address getOrElse (throw new NoSuchElementException("..."))
    }
    maybeCountry <- findCountry(address.addressId)
    country <- Future.successful {
        maybeCountry getOrElse (throw new NoSuchElementException("..."))
    }
  } yield country.code
}
»Exceptional cases|In order for us to conform with the first monad we need to deal with exceptional cases at some point in the sequence to get to the actual <code>person</code>«

While we’ve got the logic working now, and we can also use extractors, we’re in a situation where we’re forced to deal with the None cases. We also have a ton of boilerplate type conversion with Future.successful. The type conversion is necessary because in a for comprehension you can only use a type of Monad. So if we start with Future, we have to stay in it’s monadic context by lifting anything we compute sequentially to a Future whether or not it’s async.

This is a commonly encountered problem, especially in the context of async microservices. So how can we reconcile the fact that we’re mixing Option and Future?

Monad Transformers to the Rescue!

Monad Transformers enable you to combine two monads into a super monad. In this case, we’re going to use OptionT from ScalaZ.

[OptionT] has the form of OptionT[F[_], A].

This means that for any monad F[_] surrounding an Option[A] we can obtain an OptionT[F[_], A]. So our specialization OptionT[Future, A] is the OptionT transformer around values that are Future[Option[A]].

In this case the shape we care about can be simplified to

type Result[A] = OptionT[Future, A]

We can now lift any value to a Result[A] which looks like this:

@ "".point[Result]
res18: cmd17.Result[java.lang.String] = OptionT(scala.concurrent.impl.Promise$DefaultPromise@64e088bb)

And back to the Future[Option[A]] running the transformer

@ "".point[Result].run
res19: scala.concurrent.Future[scala.Option[java.lang.String]] = scala.concurrent.impl.Promise$DefaultPromise@6da9756d

So how would our function look if we implemented it with Monad Transformers?

def getCountryCode(personId : String): Future[Option[String]] = {

  val result : OptionT[Future, A] = for {
    «person» <- OptionT(findPerson(personId))
    address <- OptionT(Future.successful(person.address))
    country <- OptionT(findCountry(address.addressId))
    code <- OptionT(Future.successful(country.code))
  } yield code

  result.run
}
»OptionT binding|<code>OptionT</code> allows us to bind directly to the underlying Option value accessing the <code>person</code> without having to resolve the <code>Future</code> and <code>Option</code> individually«

Here we no longer have to deal with the None cases, and the binding to the values on the left side are already the underlying values we want to focus on. We have automatically flatMapped through the Future and Option in a singular expression.

You may notice that we’re still getting a little boilerplate action that’s resulting from lifting each value type to the OptionT. It’s okay though because now there’s no need for nested flatMaps or manually handling the optional cases with extractors and case expressions. But how can we further reduce the boilerplate of OptionT in for comprehensions?

If you’re dealing with this type of code and it’s frequently appearing, it may make sense to introduce a little DSL to lift the items you’re comprehending over to OptionT.

Now let’s introduce an object that will help with the syntax:

object ? {

    def <~[A] (v : Future[Option[A]]) : Result[A] = OptionT(v)

    def <~[A] (v : Option[A]) : Result[A] = OptionT(Future.successful(v))

    def <~[A] (v : A) : Result[A] = v.point[Result]

}

Now we can rewrite our function like this:

def getCountryCode(personId : String): Future[Option[String]] = {

  val result : Result[String] = for {
    person <- ? <~ findPerson(personId)
    address <- ? <~ person.address
    country <- ? <~ findCountry(address.addressId)
    code <- ? <~ country.code
  } yield code

  result.run

}

Now we’re no longer having to manually lift or wrap expressions around OptionT since ? <~ does it automatically for us. At this point we can fly through Future[Option[A]] getting to A on the left side without the boilerplate of manual type conversion and flatMap required. Finally, we run the transformer to get the underlying value that will be of type Future[Option[A]].

Conclusion

Monad Transformers are a beneficial abstraction to help us deal with unrelated monad containers such as Future and Option.

We can make a case for using Monad Transformers in situations where you need to combine more than one monad to comprehend over the underlying nested values. This reduces the type conversion boilerplate BUT be careful not to abuse them since they can easily create more issues than they solve.

In addition to OptionT, there are plenty of other transformers that can be useful depending on the context. For example, EitherT can be applied to all the concepts above but operates over Disjunction \/ instead of Option. Similarly ListT, StateT, StreamT and many more are available via ScalaZ.

As always, thanks for reading! If you have any questions or suggestions, don’t hesitate to start the conversation on Facebook, Twitter, or in the comments below.

Ensure the success of your project

47 Degrees can work with you to help manage the risks of technology evolution, develop a team of top-tier engaged developers, improve productivity, lower maintenance cost, increase hardware utilization, and improve product quality; all while using the best technologies.