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
You can find complete examples of the concepts discussed on my blog in my Github repo . In particular, check out the Functors example
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 1.0.1 and you can add it to your SBT build as usual:
libraryDependencies += "org.typelevel" %% "cats-core" % "1.0.1"
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 i.e. List, Option, Future etc. 1 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 write:
def withVat(orders: List[LineItem]) = orders.map(...)
def withVat(maybeOrder: Option[LineItem]) = maybeOrder.map(...)
def withVat(eventualOrder: Future[LineItem]) = eventualOrder.map(...)
...
but 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 F
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 create a Functor for F
using 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.
Cats 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 Som
e 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.
cats.syntax.option._
then write something like val maybeLineItem = LineItem(10.0).some
.
The .some
call is equivalent to Some(LineItem(10.0))
but it tells the compiler to treat the Some
as an Option
Why is all this significant?
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 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. So why is this significant?
Well firstly it may be useful to have some generic code that can handle Options, Futures, Lists etc but the real power comes when we need to test it. Take our original withVat function that accepts Futures:
def withVat(eventualOrder: Future[LineItem]): Future[LineItem] = ???
To test this we need to create an instance of a Future and then block/await for the result or otherwise use something
like Scalatest’s async specs. It’s a bit nasty. By accepting a generic Functor
we can pass an option in the test:
def withVat[F[_]](order: F[LineItem])(implicit ev: Functor[F]): F[LineItem] = ???
...
withVat(LineItem(10.0).some) shouldBe LineItem(12.0).some
cats also includes a helper Type called Id
which lets us pass “raw” types in e.g.
import cats.Id
...
withVat(LineItem(10.0): Id[LineItem]) shouldBe LineItem(12.0)
It’s just another compiler trick with tells the compiler to treat A
as F[A]
Simplifying the code
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 map from A to 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(applyVat))
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)(applyVat)
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(applyVat)
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(applyVat)
What next?
I’ve covered map
now it’s time to cover flatMap
. To do so you need to read about Monads …
Laws
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