Taming Cats - Monad

The third one down the list is Monad.

Monad

The following 3 lines summarize cats’ definition of Monad.

trait Monad[F[_]] extends cats.Applicative[F] {
  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
}

The Monad typeclass extends Applicative. This gives instances map, pure, and ap functions. Furthermore, Monads have flatMap.

flatMap is like map. It takes a function as an argument, and executes it. Where map takes an A => B, flatMap requires an A => F[B], but they both return an F[B]. flatMap allows us to chain sequential effects.

Laws

Associativity

The result from chaining calls to flatMap should be the same as nested ones. This is similar enough to Functor’s composition law.

val f = (a: Int) => Option(a * 2)
val g = (a: Int) => Option(a.toString)

assert(
  Option(1).flatMap(f).flatMap(g) ==
  Option(1).flatMap(i => f(i).flatMap(g)))

Consistency

Parallel execution, ap, should return the same result to sequential execution, flatMap.

val f = (a: Int) => Option(a * 2)
assert(
  Option(f).ap(Some(1)) ==
  Option(f).flatMap(f => Some(1).map(f)))

Example

A concrete example will make Monads easier to understand.

Loyalty programs encourage existing customers to come back. In exchange for discounts, users allow the store to record their purchase history.

A customer gives their loyalty card and their cart at the counter.

import java.util.UUID

object Counter {
  def purchaseWithLoyaltyCard(
    loyaltyCardId: UUID,
    itemIds: cats.data.NonEmptyList[UUID],
  ): Unit = ???
}

The first step is to identify the customer behind the card. This requires a user reader.

case class User(id: UUID)
trait UsersReader {
  def fromLoyaltyCard(loyaltyCardId: UUID): User
}

class Counter(usersReader: UsersReader) {
  def purchaseWithLoyaltyCard(
    loyaltyCardId: UUID,
    itemIds: cats.data.NonEmptyList[UUID],
  ): Unit = {
    val user = usersReader.fromLoyaltyCard(loyaltyCardId)
    ???
  }
}

Followed by updates to the user’s purchase history with a writer.

trait UserPurchasesWriter {
  def add(userId: UUID, itemIds: cats.data.NonEmptyList[UUID]): Unit
}

class Counter(
  usersReader: UsersReader,
  userPurchasesWriter: UserPurchasesWriter,
) {
  def purchaseWithLoyaltyCard(
    loyaltyCardId: UUID,
    itemIds: cats.data.NonEmptyList[UUID],
  ): Unit = {
    val user = usersReader.fromLoyaltyCard(loyaltyCardId)
    userPurchasesWriter.add(user.id, itemIds)
  }
}

This implementation doesn’t leave any room for effects. This would require the UsersReader to return a User even when none exists. Using Option would make more sense, but why limit it.

trait UsersReader[F[_]] {
  def fromLoyaltyCard(loyaltyCardId: UUID): F[User]
}

trait UserPurchasesWriter[F[_]] {
  def add(
    userId: UUID,
    itemIds: cats.data.NonEmptyList[UUID],
  ): F[Unit]
}

class Counter[F[_]](
  usersReader: UsersReader[F],
  userPurchasesWriter: UserPurchasesWriter[F],
) {
  def purchaseWithLoyaltyCard(
    loyaltyCardId: UUID,
    itemIds: cats.data.NonEmptyList[UUID]
  ): F[Unit] = {
    val fUser = usersReader.fromLoyaltyCard(loyaltyCardId)
    userPurchasesWriter.add(fUser.id, itemIds) // Compilation error
  }
}

fUser.id will now throw a compilation error.

The id attribute doesn’t exist for F[User]. Functor’s map would create an F[F[Unit]]. To avoid this Russian doll effect, Monad’s flatMap is required.

import cats.implicits._

class Counter[F[_]: cats.Monad](
  usersReader: UsersReader[F],
  userPurchasesWriter: UserPurchasesWriter[F],
) {
  def purchaseWithLoyaltyCard(
    loyaltyCardId: UUID,
    itemIds: cats.data.NonEmptyList[UUID]
  ): F[Unit] = {
    val fUser = usersReader.fromLoyaltyCard(loyaltyCardId)
    fUser.flatMap { user =>
      userPurchasesWriter.add(user.id, itemIds)
    }
  }
}

Or, with a for comprehension

class Counter[F[_]: cats.Monad](
  usersReader: UsersReader[F],
  userPurchasesWriter: UserPurchasesWriter[F],
) {
  def purchaseWithLoyaltyCard(
    loyaltyCardId: UUID,
    itemIds: cats.data.NonEmptyList[UUID]
  ): F[Unit] = for {
    user <- usersReader.fromLoyaltyCard(loyaltyCardId)
    _ <- userPurchasesWriter.add(user.id, itemIds)
  } yield ()
}

Counter is now capable of tracking customer purchases. It executes effectful events sequentially stopping at the first error. All this using an abstract reader, and writer.


To wrap up, Monads are abstractions to chain effectul functions. That might sound scary, but it is nothing more than flatMap, and a few laws.