Fun with Functors using Cats and Scala

You're probably familiar with the map method in the Scala standard library. Collections, Futures and Options all have a map method but unfortunately there's no base class for mappable types, making it hard to write generic code

TL;DR Cats includes the Functor type class with implementations for Futures, Options, Lists etc. It essentially gives us the base class/trait that's missing in the standard library

What is a type class?

If you're new to the concept of type classes I suggest you read my other article explaining them. The Cats library makes extensive use of type classes and a basic understanding is a prerequisite for this article

Cats

Cats is a functional programming library which supports many advanced functional programming paradigms borrowed from languages such as Haskell. However you don't need a detailed understanding of category theory or functional programming to get value from Cats. In my own experience most people (myself included) benefit mostly from the more simple abstractions.

Cats defines type classes for various functional concepts along with implementations for common types. Of course, being type classes you can write your own implementations if a particular type is not supported "out of the box". Today we'll be looking at one of the most basic type classes - the Functor

Getting started with cats is pretty simple. At the time of writing the latest stable release is 0.9.0 and you can add it to your SBT build as usual:

libraryDependencies += "org.typelevel" % "cats_2.12" % "0.9.0"  

What is a Functor?

In simple terms any type constructor (type wrapping another type) that has a map method can be thought of as a Functor 1 i.e. List, Option, Future etc. The problem is that although we know List, Option and Future all have a map method the standard library has no base type/trait to represent this so we can't write:

def withVat(order: Functor[LineItem]) = order.map(...)  

The Cats Functor type class

However using Cats' Functor type class we can write such a method:

import cats.Functor

case class LineItem(price: Double)

def withVat[F[_]](order: F[LineItem])  
                 (implicit ev: Functor[F]): F[LineItem] = {
  Functor[F].map(order)(o => o.copy(price = o.price * 1.2))
}

Let's decode the method signature. The method is parameterised based on a type of F[_]. This means any type constructor e.g. Option, List, Future etc. At this stage we haven't specified anything about the type needing a map method. The parameter itself is of type F[LineItem] i.e. any type wrapping a LineItem. Finally we have the implicit parameter ev: Functor[F] which means we must have a type class implementation in place which allows us to treat F as a Cats Functor.

In the method body we call Functor[F].map(...) i.e. we convert the order to a Functor based on the implicit "evidence" and call it's map method. Our method should now compile but if we try to call it we'll run into a problem:

val lineItems = List(LineItem(10.0), LineItem(20.0))  
withVat(lineItems).foreach(println)

Error:(15, 12) could not find implicit value for parameter ev: cats.Functor[List]  

The compiler is telling us that we need to supply the evidence that List is a cats.Functor i.e. we need a type class implementation for List. The Cats library includes such an implementation already so we can just use this:

import cats.Functor  
import cats.instances.list._

def withVat[F[_]](...)

val lineItems = List(LineItem(10.0), LineItem(20.0))  
withVat(lineItems).foreach(println)  

So far so good, lets try to use an Option instead of a List. Again we'll need to pull in the type class implementation for Option:

import cats.Functor  
import cats.instances.list._  
import cats.instances.option._

val maybeLineItem = Some(LineItem(10.0))  
withVat(maybeLineItem).foreach(println)

Error:(16, 12) could not find implicit value for parameter ev: cats.Functor[Some]  

Hmmm, seems it didn't work. What went wrong? Actually we've run into an issue of variance. Cat's includes an implementation for Option but we're passing Some. We might expect that Functor[Some] would be treated as Functor[Option] (known as Covariance) but in fact Cats is generally invariant of types i.e. it wants an Option and only an Option, a Some or None won't do. We need to tell the compiler to treat our Some as an Option:

val maybeLineItem: Option[LineItem] = Some(LineItem(10.0))  
withVat(maybeLineItem).foreach(println)  

As you work with Cats you'll see this is a common theme so remember this compiler trick - you'll need it again for sure.

Summary

By using the Functor type class we can abstract over anything that can be mapped. We're not restricted to the types in the standard library, we could add a map 1 method to our own types. So long as we write a Functor type class implementation for it we would pass it to our withVat function above.

What next?

Cats also introduces a concept of syntax or extension methods. This concept is implemented using implicit classes and allows us to write order.map(...) instead of Functor[F].map(order)(...). We can also drop the implicit parameter by specifying that type F[_] is a Functor

...
import cats.syntax.functor._

def withVat[F[_]: Functor](order: F[LineItem]): F[LineItem] = {  
  order.map(o => o.copy(price = o.price * 1.2))
}

withVat adds VAT of 20% to LineItems but we can build a higher order function which can deal with any type:

def withFunctor[A, B, F[_]: Functor](order: F[A])(op: A => B): F[_] = order.map(op)

val lineItems = List(LineItem(10.0), LineItem(20.0))  
withFunctor(lineItems)(_.price * 1.2).foreach(println)  

Of course this is a contrived example as withFunctor adds no value over a simple inline call but it illustrates the point that A, B & F can be anything so long as the caller of the method:

  • Supplies evidence that F is a Functor (an implementation)
  • Knows how to handle A and B

A Different view

All the examples I have given so far assume we want to write generic methods capable of handling Lists, Options, Futures etc and this is certainly a common use case for Functors. However we can also use functors inline in our code and this is especially useful when composing them. A common pattern we often see is something like:

val order: Future[Option[Order]] = fetchOrder(...)  
order.map(_.map(calculateTotalPrice))  

The nested map call is messy but as Cats includes Functor implementations for both Future and Option we can compose them:

import cats.Functor  
import cats.instances.future._  
import cats.instances.option._  
...
Functor[Future].compose[Option].map(order)(calculateTotalPrice)  

We can cut down the boilerplate by writing our own implicit class which adds a nestedMap method to all nested Functors

implicit class RichFunctor[A, F[_]: Functor, G[_]: Functor](underlying: F[G[A]]) {  
  def nestedMap[B](op: A => B): F[G[B]] = Functor[F].compose[G].map(underlying)(op)
}

val order: Future[Option[Order]] = fetchOrder(...)  
// use the new implicit method we defined above
order.nestedMap(calculateTotalPrice)  

This will work for Future[Option[A]] but it will also work for any combination of type constructors so long as we have the Functor type class implementations in scope:

...
import cats.instances.future._  
import cats.instances.list._

val orders: Future[List[Order]] = fetchOrder(...)  
orders.nestedMap(calculateTotalPrice)  
  1. I'm oversimplifying things a bit here - for a typeclass to be a Functor it must obey two laws: Firstly it should be possible to compose two map calls. Secondly mapping with the identity function should have no effect. You can read more on the cats website