1. Introduction

In this tutorial, we’ll look at Scala Cats, a Scala library that provides abstractions supporting a typeful, functional programming style. The Cats library contains a large variety of tools for functional programming. It delivers these tools majorly in the form of type classes that we can apply to the existing Scala types.

2. SBT Dependencies

To start, let’s add the Cats library to our dependencies:

libraryDependencies += "org.typelevel" %% "cats-core" % "2.2.0"

Here we are using version 2.2.0 of the Cats library.

3. Type Classes

A type class is a pattern in programming originating in Haskell. It allows us to extend existing libraries with new functionality, without using traditional inheritance, and without altering the original library source code. In Scala Cats, components of type classes can be specified as:

  • Type class
  • Instances of type class
  • Interface objects
  • Interface syntax

3.1. Type Class

A type class is an API or an interface that represents some functionality that we want to implement. It is represented by a trait with at least one type parameter. Let’s define a type class Area:

trait Area[A] {
  def area(a: A): Double
}

Cats tries to provide a single generic implementation for every type and pull out the necessary pieces into a trait like Area[A].

3.2. Type Class Instances

The role of the type class instance is to provide an implementation of the type class for a specific type. We define instances by creating concrete implementations of the type class and tagging them with the implicit keyword:

case class Rectangle(width: Double, length: Double)
case class Circle(radius: Double)

object AreaInstances {
  implicit val rectangleArea: Area[Rectangle] = new Area[Rectangle] {
    def area(a: Rectangle): Double = a.width * a.length
  }
 
  implicit val circleArea: Area[Circle] = new Area[Circle] {
    def area(a: Circle): Double = Math.PI * (a.radius * a.radius)
  }
}

These type class instances are also known as implicit values. They are now the candidate type class instances for types Rectangle and Circle.

3.3. Interface Objects

The simplest way of creating an interface that uses a type class is to place methods in a singleton object:

object ShapeArea {
  def areaOf[A](a: A)(implicit shape: Area[A]): Double = shape.area(a)
}

To use this object, we have to import any type class instances we care about and then call the relevant method:

import AreaInstances._ 
ShapeArea.areaOf(rectangle)

The compiler spots that we have called the areaOf method without providing the implicit parameters. It tries to fix this by searching for candidate type class instances of the relevant type and inserting them at the call site:

ShapeArea.areaOf(rectangle)(rectangleArea)

3.4. Interface Syntax

Alternatively, we can use extension methods to extend existing types with interface methods. Cats refer to this as “syntax” for the type class:

object ShapeAreaSyntax { 
  implicit class ShapeAreaOps[A](a: A) { 
    def areaOf(implicit shape: Area[A]): Double = shape.area(a)
  } 
}

We use interface syntax by importing it alongside the instances for the types we need:

import AreaInstances._
import ShapeAreaSyntax._
Rectangle(2, 3).areaOf

Now again, the compiler searches for candidates for the implicit parameters and fills them out for us:

Rectangle(2, 3).areaOf(rectangleArea)

4. Implicits

Working with type classes in Scala means we have to work with implicit values and implicit parameters. Any definitions marked implicit in Scala must be placed inside an object or trait rather than at the top level.  We can package our type class instances or implicit values by placing them in an object such as AreaInstances, a trait, or companion object of the type class.

4.1. Implicit Scope

As we saw above, the compiler will search for the candidate type class instances by type. For example, in the following expression it will look for an instance of type Area[Rectangle]:

ShapeArea.areaOf(Rectangle(2, 3))

Compiler search for the candidate type class instances in implicit scope. The implicit scope is applied at the call site, which is where we call a method with an implicit parameter. Definitions will only be included in implicit scope if they are tagged with the implicit keyword. Additionally, the compiler will fail with an ambiguous implicit values error if it sees multiple candidate definitions. Cats provides this implicit mechanism to allow automatic derivation of candidate type class instances.

5. The Cat Show

What does this all have to do with Cats? In our examples so far, we created our own type classes. The Cats library is written using a modular structure that helps us in choosing the type classes, instances, and interface methods that are already built, tested, and ready to use. Cats has a built-in Show[A] type class defined in cats package:

package cats

trait Show[A] {
  def show(value: A): String
}

Show is an alternative to the Java toString method. Although toString already serves the same purpose and case classes provide sensible implementations for toString, toString is defined on Any(Java’s Object) and can therefore be called on anything, not just case classes. To avoid this unwanted behavior, Cats provides the toString equivalent as a type class instead of the root of the class hierarchy.

Show allows us to only have String conversions defined for the data types we actually want.

5.1. Importing Type Classes

We can import Show directly from this package:

import cats.Show

Each Cats type class’s companion object has an apply method that locates an instance for whichever type we specify.

5.2. Importing Default Instances

The cats.instances package provides us with default type class instances for various types, including IntStringList, and Option. We can simply import these built-in type class instances of Show for Int and String:

import cats.instances.int._ // for Show
import cats.instances.string._ // for Show

val showInt: Show[Int] = Show.apply[Int]
val showString: Show[String] = Show.apply[String]

We now have access to two instances of Show and can use them to print Ints and Strings:

val intAsString: String = showInt.show(123)
assert(showInt.show(123) == "123")
val stringAsString: String = showString.show("abc")
assert(showString.show("abc") == "abc")

5.3. Importing Interface Syntax

We can make Show a little easier to use by importing the built-in syntax interface from cats.syntax.show. This adds an extension method called show to any type for which we have an instance of Show in scope:

import cats.syntax.show._ // for show
val shownInt = 123.show 
assert(123.show == "123")
val shownString = "abc".show
assert("abc".show == "abc")

It is simpler and faster to import all of the standard type class instances and all of the syntax in one go:

import cats.implicits._

5.4. Defining Custom Instances

We can also define an instance of Show simply by implementing the trait for a given type:

object CustomInstance extends App {
  implicit val customShow: Show[Date] =
    new Show[Date] {
      def show(date: Date): String =
        s"${date.getTime}ms since the epoch."
    }
}

val actualDate: String = new Date().show
  val expectedDate: String = s"This year is: ${new Date().getYear}"

assert(actualDate == expectedDate)

Cats also provides Single Abstract Method (SAM) to simplify the above expression:

implicit val customShow: Show[Date] =
    Show.show((date: Date) => s"${date.getTime}ms since the epoch.")

6. Semigroups

Cats provides a special type class Semigroup to aggregate data, which comes with a method combine that simply combines two values of the same data type by following the principle of associativity. The combine method is constructed as:

trait Semigroup[A] {
    def combine(x: A, y: A): A
}

It can be implemented as:

import cats.kernel.Semigroup
import cats.instances.int._
val onePlusTwo = Semigroup[Int].combine(1, 2)

Associativity is the only law for Semigroups that means the following equality must hold for any choice of a, b and c.

combine(a, combine(b, c)) = combine(combine(a, b), c)

Associativity allows us to partition the data any way we want and potentially parallelize the operations. We can also define custom Semigroup by providing our own implementation of the combine method for all common types in the Scala ecosystem. For example, we can provide our own implementation of the combine method for type Int by multiplying the two integers rather than performing the default addition.

implicit val multiplicationSemigroup = new Semigroup[Int] {
  override def combine(x: Int, y: Int): Int = x * y
} 

// uses our implicit Semigroup instance above
val four = Semigroup[Int].combine(2, 2)

Cats also allows us to provide an implementation of combine in a more concise way:

implicit val multiplicationSemigroup = Semigroup.instance[Int](_ * _)

We can also define Semigroup for collections by using Scala’s fold() or recursion to operate on the collection of values:

def combineStrings(collection: Seq[String]): String = {
  collection.foldLeft("")(Semigroup[String].combine)
}

However, given just Semigroup, we cannot write the above expression generically. That means we cannot write a generic method combineAll(collection: Seq[A]): [A] for the above expression because the fallback value will depend on the type of A (“” for String, 0 for Int, and so on). There is a solution to this problem, though, and it’s called the Monoid.

7. Monoids

Cats provides another special type class, Monoid, that extends Semigroup. Monoids add a default or fallback value for the given type. Monoid type class comes with two methods – one is the combine method of Semigroups, and another one is the empty method that performs identity operation. The signature of the Monoid type class can be specified as:

trait Monoid[A] extends Semigroup[A] {   
  def empty: A 
}

This empty element is basically the default value that we’re passing into the combineStrings method. It resolves the shortcoming of Semigroups. Now we can easily provide the implementation of generic method combineAll(collection: Seq[A]): [A] using Monoids:

def combineAll[A](collection: Seq[A])(implicit ev: Monoid[A]): A = {
  val monoid = Monoid[A]
  collection.foldLeft(monoid.empty)(monoid.combine)
}

Since Semigroups follow the principle of associativity, the same rules are applied to Monoids as well. The combine operation has to be associative and empty value should be an identity for the combine operation:

combine(x, empty) = combine(empty, x) = x

So it’s clear the empty (identity) element depends on the context, not just on the type. That’s why Monoid (and Semigroup) implementations are specific not only to the type but also the combine operation. Monoids can be combined to form bigger Monoids or write more generalized functions.

8. Conclusion

In this article, we have looked at the Cats library. We examined type classes, implicits, and general patterns in Cats type classes. The type classes themselves are generic traits in the Cats package. Additionally, we looked into some other type classes provided by Cats – Semigroups and Monoids. As usual, the source code presented here can be found over on GitHub.

guest
0 Comments
Inline Feedbacks
View all comments