Higher Kinded Play Framework

Posted on 2018-03-04

Higher order functions are functions that accept other functions as parameters. Higher kinded types are types that can accept other types as parameters. In Scala, the syntax F[_] is used to express that F is a higher-kinded type that can accept any type.

Advantages

Consider the following example; using a higher-kinded type in a service interface to retrieve Users by userId:

trait UserService[F[_]] {
  def getUser(id: UserId): F[User]
}

When using Play the F[_] will almost definitely be Future when we run the code. Why not just use Future directly if we already know we’re going to use it at runtime? Well, Future is a very specific class; it is an asynchronous success or an asychronous exception. That influences how to write and test code using it, in ways that can be detrimental.

Firstly, does your code need to know that things are asynchronous, or does it only need to sequence operations together?

for {
  x <- thisThing()
  y <- thatThing(x)
} yield (x, y)

We don’t care if we’re using Futures in the code above. If we use F[_]: Monad instead of Future we can still flatMap and map the two function calls and yield the same result. F[_]: Monad says to the compiler, “choose anything you like, as long as it obeys the Monad laws”. This means our code becomes more generic; Scala has lots of monads, including Future, Try, Option, Either, etc. Using a higher kind allows us to explicitly state what is required, but also the ability to delay choosing until runtime.

The cats library has the identity (Id) monad, which has the type:

type Id[A] = A

This type simply disappears once evaluated. The classic example of the power of the Id monad is for use in testing. Your production code can implement F[_]: Monad as a Future, but your tests can use Id. This means that your tests become synchronous, and a whole class of problems to do with async-testing disappear!

The second problem with Future is that it forces the code to use exceptions. Exceptions can be tricky. They break type-signatures, as you can no longer tell what a method returns by looking at it. It might call a method that throws an exception. You would only discover that by reading the code, or running some tests.

The solution to that particular problem is to use F[_], and pass back a type that has errors if they can happen, such as F[Either[Error, Success]]

The previous example of a UserService would become this:

trait UserService[F[_]] {
  def getUser(id: UserId): F[Either[Error, User]]
}

Disadvantages

Using higher-kinded types instead of Future everywhere has a teaching and communication cost. That cost depends on who you’re working with, but I’ve seen people successfully get up to speed with this style (plus using the EitherT monad-transformer) in only a few days.

The most tangible disadvantage I’ve experienced is IntelliJ being slow after importing all of the cats-library implicitly. A partial solution to this is to only import the implicits that you’re using:

import cats.Monad
import cats.syntax.functor._ // for `map` to work
import cats.syntax.flatMap._ // for `flatMap` to work
import cats.instances.future._ // for an instance of `Monad[Future]`

IntelliJ also sometimes requires extra convincing that code compiles. SBT can compile things that IntelliJ’s error-highlighting complains about. Adding in type definitions and splitting into smaller methods usually works, but it is sometimes annoying.

You may also encounter strange Guice exceptions, and wiring issues, such as:

[info] com.google.inject.CreationException: Unable to create injector, see the following errors:
[info] 
[info] 1) No implementation for cats.Monad<scala.concurrent.Future> was bound.
[info]   while locating cats.Monad<scala.concurrent.Future>
[info]     for the 1st parameter of ...

These errors can be solved by providing Guice with a Monad[Future]. Fortunately, the cats-library can create one from an ExecutionContext:

import cats.Monad
import cats.instances.future.catsStdInstancesForFuture

@Provides
def futureMonad(implicit ec: ExecutionContext): Monad[Future] = catsStdInstancesForFuture(ec)

Conclusion

In conclusion, despite some tooling-related annoyances and a small learning curve, I would encourage exploring this style of Scala when using the Play Framework. Code becomes more explicit, and easier to reason about as there are no exceptions, unless an exceptional scenario occurs, like an OutOfMemoryException.