Skip to content

Instantly share code, notes, and snippets.

@felher
Last active July 7, 2018 09:26
Show Gist options
  • Save felher/a34c18226dee47fb99a847cf88e40dce to your computer and use it in GitHub Desktop.
Save felher/a34c18226dee47fb99a847cf88e40dce to your computer and use it in GitHub Desktop.

The Mysterious Case of The Missing flatMap

When you're starting to use functional programming libraries in scala there is something which might not feel right to you. But you suppress that feeling because everything seems to work just a fine. You try to move on, but a question is racing through your head. I'm talking of course about:

Why the hell can I call flatMap and map on my data types, even though it isn't implemented on my data types?

Consider a small example. We will write our own mirror of the Option data type. The original Option has two cases, Some(a) if there is a value and None if there is none. Let's call our data type Maybe which the cases Just(a) and Empty.

sealed trait Maybe[+A]
final case class Just[A](a: A) extends Maybe[A]
case object Empty extends Maybe[Nothing]

So far, so good. Since we know that Option is a monad, Maybe should be too. Let's encode that by using the following Monad type class:

trait Monad[F[_]] {
  def unit[A](a: A): F[A]
  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
}

A simple monad type class, giving us unit and flatMap for our F. Let's give a Monad instance to our Maybe type.

object Maybe {
  implicit val maybeMonad = new Monad[Maybe] {
    def unit[A](a: A): Maybe[A] = Just(a)
    def flatMap[A, B](fa: Maybe[A])(f: A => Maybe[B]): Maybe[B] =
      fa match {
        case Empty   => Empty
        case Just(a) => f(a)
      }
  }
}

What do we have here? We are defining an implicit instance of the type Monad[Maybe] on the companion object of our type. This instance implements unit by wrapping the given value a within a Just. And it implements flatMap just as it is defined on Option, namely: if we get an Empty, return an Empty. Otherwise, take the value within the monad and pass it to the given function f.

Okay, so here's the problem: If flatMap is not defined on Maybe[X], but only on Monad[Maybe], why can I call flatMap if I have only something of type Maybe[X]? Or phrased differently, why does this compile:

val a: Maybe[Int] = Just(3)
a.flatMap(i => Just(i + 3))

It shouldn't, because there is no flatMap on Maybe[Int]. And behold, it doesn't compile. But there is a way to get it to compile and this is how functional programming libraries do it. Whenever the compiler reaches a point where it can't compile something because a method seems to be missing on some type, it doesn't just give up. Instead, it searches for an implicit class (or def) which turns the type into something that has the method the compiler needs. Another quick example.

println(3.triple) doesn't compile. But if you have defined an implicit class like the following, it compiles just fine.

implicit class IntOps(i: Int) {
  def triple: Int = i * 3
}

When compiler tries to call triple on an Int and fails, it searches for an implicit class (or def) which takes an Int and turns it into something which has this method defined. The IntOps class matches the requirements exactly. It takes an Int, it is marked as implicit and it has the needed method defined. So the compiler rewrites our code into something equivalent to println(IntOps(3).triple) which compiles and works.

Our use case with monads is a bit more complicated, but not by much. When we write a.flatMap(i => Just(i + 3)), the compiler fails to resolve method flatMap on a. So it now looks for an implicit class (or def) which takes a Maybe[Int] and adds the flatMap operation. We can provide such a class like so:

implicit class MonadOps[F[_], A](fa: F[A])(implicit ev: Monad[F]) {
  def flatMap[B](f: A => F[B]): F[B] = ev.flatMap(fa)(f)
}

Now there are a few things here. First of all, our implicit class doesn't take a Maybe[Int] but an F[A]. This isn't a problem. As long as the compiler can unify the types, we are fine. In this case, we can substitute F[_] with Maybe[_] and A with Int to get our types to align. This leaves us with MonadOps(fa: Maybe[Int]) which is exactly what we need.

The other thing that's different is that we are requiring another parameter of type Monad[F]. That's not a problem either, because the compiler sees that the parameter is marked with implicit. It then searches for instances of the type F marked with implicit themselves. Such implicit resolution always looks on the companion object of the type we need for implicit instances. Since the compiler substituted F with Maybe, it searches on object Maybe {...} for the implicit instance. Where it finally finds it.

In the end, the compiler rewrites our initial code (a.flatMap(i => Just(i + 3))) to something along the lines of MonadOps[Maybe, Int](a)(Maybe.maybeMonad).flatMap(i => Just(i + 3)).

And this is how flatMap gets onto our data types. Here is the complete code if you want to play with it:

object Main {
  trait Monad[F[_]] {
    def unit[A](a: A): F[A]
    def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
  }

  implicit class MonadOps[F[_], A](fa: F[A])(implicit ev: Monad[F]) {
    def flatMap[B](f: A => F[B]): F[B] = ev.flatMap(fa)(f)
  }

  sealed trait Maybe[+A]
  final case class Just[A](a: A) extends Maybe[A]
  case object Empty extends Maybe[Nothing]

  object Maybe {
    implicit val maybeMonad = new Monad[Maybe] {
      def unit[A](a: A): Maybe[A] = Just(a)
      def flatMap[A, B](fa: Maybe[A])(f: A => Maybe[B]): Maybe[B] =
        fa match {
          case Empty   => Empty
          case Just(a) => f(a)
        }
    }
  }

  def main(args: Array[String]): Unit = {
    val a: Maybe[Int] = Just(3)
    println(a.flatMap(i => Just(i + 3)))
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment