Field Notes

Cats Core, Part 5: Combining Optional Amounts with Apply and Applicative

Cats Core, Part 5: Combining Optional Amounts with Apply and Applicative

In Part 4 I gave my own Quote type a Functor, so one discount function could run inside List, Option, or Quote. Functor transforms one value inside its context. A checkout rarely has one value. It has a price and a delivery cost, and either of them can be unavailable.

This part covers what Cats adds when two or more independent values need to be combined: Apply, and then Applicative. The examples use Scala 2.13.18 and Cats Core 2.13.0, with amounts in one currency.

Two optional amounts, and the wrong shape

Both inputs are Option[BigDecimal]. The total should be Some(15), or None when either amount is missing. With only Functor's map, the obvious attempt nests one map inside the other:

import cats._
import cats.data._
import cats.syntax.all._

val price: Option[BigDecimal] = Some(BigDecimal(12))
val delivery: Option[BigDecimal] = Some(BigDecimal(3))

val nested: Option[Option[BigDecimal]] =
  price.map(p => delivery.map(d => p + d))
// Some(Some(15))

Trace the types from the inside out. The inner map returns Option[BigDecimal]. The outer map wraps whatever its function returns, so it wraps that Option again. map never adds a layer by itself; the extra layer appears because the function I handed to the outer map already returns an Option. Functor can open one context at a time. It has no rule for merging two contexts into one.

mapN: open both, compute once, wrap once

val total: Option[BigDecimal] =
  (price, delivery).mapN((p, d) => p + d)
// Some(15)

val unknownDelivery: Option[BigDecimal] =
  (price, Option.empty[BigDecimal]).mapN((p, d) => p + d)
// None

The lambda receives two plain BigDecimal values and returns a plain BigDecimal. Cats takes care of the Option around the inputs: when both are present, the result is wrapped once; when one is missing, there is nothing to add and the result is None. A missing delivery cost is not free delivery. Some(0) is a known amount; None is an unknown one.

Nested map turns Some(12) and Some(3) into Some(Some(15)). mapN turns the same inputs into Some(15).
Nested map leaves one Option inside another. mapN opens both inputs, applies a plain function, and wraps the result once.

I first met mapN in Part 1, where it built a Policy from optional fields. What I had not looked at was the capability behind it.

Writing the rule once with Apply

The checkout rule should not care whether the amounts are optional, a list of alternatives, or a validated value. So I write it against a generic F:

def checkoutTotal[F[_]](
    prices: F[BigDecimal],
    deliveryCosts: F[BigDecimal]
)(implicit applyF: Apply[F]): F[BigDecimal] =
  (prices, deliveryCosts).mapN((price, delivery) => price + delivery)

val optionTotal: Option[BigDecimal] = checkoutTotal(price, delivery)
// Some(15)

val combinations: List[BigDecimal] = checkoutTotal(
  List(BigDecimal(12), BigDecimal(20)),
  List(BigDecimal(3), BigDecimal(5))
)
// List(15, 17, 23, 25)

Why the implicit Apply[F], if mapN already solves the problem? Because mapN is syntax. It needs an instance that knows how to merge two values of type F. With a concrete Option, the compiler finds that instance without help. Inside generic code, F is unknown, so the function has to ask its caller. Removing the parameter turns this into a compile error.

At the call site I never pass anything. The compiler infers F = Option, sees the missing Apply[Option], and searches the implicit scope, which includes the companion objects of Apply and its parent type classes. In Cats 2.13.0 it finds Invariant.catsInstancesForOption, a Monad[Option], and a Monad is also an Apply. No import is needed for the instance; cats.syntax.all._ provides only the syntax.

The same function gives a different result for List. Apply for List pairs every price with every delivery cost: 12+3, 12+5, 20+3, 20+5. It does not zip by position. Use it only when every pairing makes sense. My function supplies the addition; the instance decides how the contexts combine.

Apply is a Semigroupal, not a Semigroup

This name tripped me up. Semigroup, from Part 2, combines two plain values of the same type: 12 |+| 3 gives 15. Semigroupal combines two contexts into one context holding a pair, without looking at the values. Apply is Functor plus Semigroupal, and Cats defines map2 exactly that way: map(product(fa, fb))(f.tupled).

val paired: Option[(BigDecimal, BigDecimal)] =
  Semigroupal[Option].product(price, delivery)
// Some((12,3))

val throughOwner: Option[BigDecimal] =
  Apply[Option].map2(price, delivery)((p, d) => p + d)
// Some(15)

val explicitInstance: Option[BigDecimal] =
  checkoutTotal(price, delivery)(Apply[Option])
// Some(15)
product turns Some(12) and Some(3) into Some((12, 3)), then map with addition gives Some(15). A comparison shows Semigroup combining values and Semigroupal combining contexts.
map2 pairs the contexts with product, then maps the plain function over the pair. Semigroup works on values; Semigroupal works on contexts.

mapN is the tuple syntax over this family: two inputs use map2, three use map3, and so on. The two concepts do meet in Validated: its Apply pairs two values, and when both are invalid it uses a Semigroup on the error type to merge the errors. That is why Part 2 needed one.

A whole cart needs a starting value

A cart has any number of line totals, each possibly unknown. Adding them one by one with mapN is a foldLeft over a List, which is the operation Foldable provides. A fold needs a starting value, and for an empty cart the starting value is the answer. Generic code cannot write Some(0), because it does not know that F is Option. Apply combines contexts that already exist; it cannot create one.

Applicative extends Apply with one method, pure: A => F[A], which puts a known value into the context:

def orderTotal[F[_]](lineTotals: List[F[BigDecimal]])(
    implicit applicativeF: Applicative[F]
): F[BigDecimal] =
  lineTotals.foldLeft(applicativeF.pure(BigDecimal(0))) { (runningTotal, lineTotal) =>
    (runningTotal, lineTotal).mapN((total, line) => total + line)
  }
val cart: List[Option[BigDecimal]] =
  List(Some(BigDecimal(12)), Some(BigDecimal(3)), Some(BigDecimal(5)))
val cartTotal: Option[BigDecimal] = orderTotal(cart)
// Some(20)

val withUnknownLine: List[Option[BigDecimal]] =
  List(Some(BigDecimal(12)), None, Some(BigDecimal(5)))
val unknownTotal: Option[BigDecimal] = orderTotal(withUnknownLine)
// None

val emptyCart: Option[BigDecimal] = orderTotal(List.empty[Option[BigDecimal]])
// Some(0)

val noAlternatives: List[BigDecimal] = orderTotal(List.empty[List[BigDecimal]])
// List(0)

The type annotations on the carts are deliberate. Without them, a list containing only Some values is inferred as List[Some[BigDecimal]], and the compiler then looks for an Applicative[Some], which does not exist.

pure(0) is not the same as empty. For List it is List(0), one alternative. Starting from List() would make every later mapN return List(), because there would be nothing to pair. I also have to choose the right neutral value myself: start from 1 and every total is off by one.

A fold starts at pure(0) = Some(0), adds Some(12), Some(3) and Some(5) with mapN to reach Some(20). A comparison shows Monoid adding empty to Semigroup and Applicative adding pure to Apply.
pure supplies the first context of the fold. It plays the same role for contexts that Monoid.empty plays for plain values.

This mirrors Part 3. Monoid added empty to Semigroup, so combining an empty batch has a result. Applicative adds pure to Apply, so folding an empty list of contexts has a result. The hierarchy so far is Functor (map), Apply (map2), Applicative (pure), and next Monad (flatMap), for when the second value depends on the first.

Run the complete example

The six Scala blocks form one sequence. In a project with Scala 2.13.18 and Cats Core 2.13.0, run sbt console, enter :paste, paste the blocks in order, and finish with Ctrl+D. I compiled and ran that exact sequence with assertions on every commented result. The learning repository contains the runnable lessons 13 and 14.

References: Typelevel’s Apply documentation and Applicative documentation. Continue through the Cats Core series.