Cats Core, Part 3: From Empty Batches to Checkout Totals
In Part 2, I gave Cats a rule for combining my own validation errors. Two reports went in; one report came out. That felt concrete. Then I tried to combine a whole batch and reached a small question with a real consequence: what should happen when the batch contains nothing?
That question led me to Monoid. A shopping-cart example then made its usefulness clearer through foldMap. I am keeping the same approach here: actual values, the Cats operation, and the rule that makes it work.
A batch can contain zero reports
The previous combination rule appended two lists of structured errors. I want to reuse it for any number of reports, including zero. An empty error report is a reasonable result: it says that this aggregation collected no errors.
I express that choice with a Monoid[ValidationErrors]. It keeps the combination rule and adds empty:
import cats.{Monoid, Semigroup}
import cats.syntax.all._
final case class FieldError(field: String, message: String)
final case class ValidationErrors(items: List[FieldError])
implicit val errorsMonoid: Monoid[ValidationErrors] =
new Monoid[ValidationErrors] {
def empty: ValidationErrors = ValidationErrors(Nil)
def combine(a: ValidationErrors, b: ValidationErrors): ValidationErrors =
ValidationErrors(a.items ++ b.items)
}
These examples use Scala 2.13.18 and Cats Core 2.13.0. Nil is Scala's empty list; it prints as List() inside our case class. With the instance in scope, Cats can combine the batch directly:
val numberErrors = ValidationErrors(
List(FieldError("number", "is missing"))
)
val premiumErrors = ValidationErrors(
List(FieldError("annualPremium", "must be positive"))
)
val reports = List(numberErrors, premiumErrors)
val combinedErrors: ValidationErrors = reports.combineAll
// Both errors, in their original order.
val noReports = List.empty[ValidationErrors]
val noErrors: ValidationErrors = noReports.combineAll
// ValidationErrors(List())
The input is List[ValidationErrors]. The output is one ValidationErrors. There is no special error-return branch for an empty batch. Cats has the neutral report available through the instance.
Empty has a rule to obey
empty is not an arbitrary fallback. Combining it on either side must leave the other value unchanged. For our reports, appending an empty list does exactly that:
val neutral = errorsMonoid.empty
assert(errorsMonoid.combine(neutral, numberErrors) == numberErrors)
assert(errorsMonoid.combine(numberErrors, neutral) == numberErrors)
These are the left and right identity laws. Associativity from the previous article still applies: regrouping list concatenation preserves the result. The example assertions illustrate identity; the behavior of list concatenation explains why our rule works for every report.
The Typelevel Monoid documentation defines this extension of Semigroup. I find the purpose easier to remember through the empty batch: a reusable aggregation now has a valid starting value.
An empty value is still a value
My first confusion was to think that Semigroup could not handle empty values. It can. An empty report is still a ValidationErrors, and our append rule can combine it with another report. What the Semigroup contract does not supply is a neutral value for zero inputs.
Cats also offers combineAllOption when I want to keep the absence of inputs visible. A Monoid is also a Semigroup, so I can use the same instance through that narrower type:
val semigroup: Semigroup[ValidationErrors] = errorsMonoid
val emptyReport = ValidationErrors(Nil)
val absent = semigroup.combineAllOption(noReports)
// None
val presentButEmpty = semigroup.combineAllOption(List(emptyReport))
// Some(ValidationErrors(List()))

For collecting errors, returning the neutral report may be exactly what I want. If I need to know whether any reports arrived, the optional result carries information I would otherwise lose. Neither result proves that a validation process ran; this example only aggregates existing data.
Now the input is a shopping cart
So far, the values in the list already had the type I wanted to return. A cart introduces another step. I have product lines, but I want a summary containing the number of individual items and their total price.
final case class LineItem(
product: String,
quantity: Int,
unitPriceCents: Long
)
final case class CheckoutTotals(itemCount: Int, totalCents: Long)
def toTotals(line: LineItem): CheckoutTotals =
CheckoutTotals(line.quantity, line.quantity * line.unitPriceCents)
toTotals describes what one line contributes. Two notebooks at 1200 cents each contribute CheckoutTotals(2, 2400). Three pens at 200 cents each contribute CheckoutTotals(3, 600). Two cart lines therefore represent five items.
I use whole cents and Long amounts here; the L suffix marks a Long literal. The example assumes already-checked, small quantities and prices. Tax and discounts would need their own domain rules.
foldMap combines the contributions
The summary's Monoid adds corresponding fields. Zero items and zero cents form its neutral value:
implicit val totalsMonoid: Monoid[CheckoutTotals] =
new Monoid[CheckoutTotals] {
def empty: CheckoutTotals = CheckoutTotals(0, 0L)
def combine(a: CheckoutTotals, b: CheckoutTotals): CheckoutTotals =
CheckoutTotals(
a.itemCount + b.itemCount,
a.totalCents + b.totalCents
)
}
val cart = List(
LineItem("Notebook", quantity = 2, unitPriceCents = 1200L),
LineItem("Pen", quantity = 3, unitPriceCents = 200L)
)
val totals: CheckoutTotals = cart.foldMap(toTotals)
// CheckoutTotals(5,3000)
The Monoid belongs to the output type: CheckoutTotals. I do not need to invent a rule for combining product names or whole cart lines. The conversion function defines each contribution, and the Monoid defines how contributions combine.

For this pure transformation over a List, I can spell out the same result with familiar operations:
assert(cart.foldMap(toTotals) == cart.map(toTotals).combineAll)
val emptyCart = List.empty[LineItem]
val emptyTotals: CheckoutTotals = emptyCart.foldMap(toTotals)
// CheckoutTotals(0,0)
foldMap expresses transformation and aggregation together. I could also write a Scala foldLeft; Cats lets me reuse the neutral value and combination rule already defined for my summary. An empty cart returns the neutral summary without asking the conversion function to invent a cart line.
The operation comes from Cats' Foldable type class. I am leaving its wider API for a later lesson. This one operation already gives me a useful division of responsibilities: transform each input, combine the results, and handle zero inputs through the output Monoid.
Run the examples
The code blocks above form a complete sequence. In a Scala 2.13.18 sbt project with Cats Core 2.13.0, open sbt console, enter :paste, paste the Scala blocks in order, and finish with Ctrl+D. The assertions should pass, and totals should be CheckoutTotals(5,3000).
I compiled and ran that sequence separately, alongside the Monoid and foldMap lessons in my learning workspace. The public learning repository has the pinned build and earlier examples; the snippets here contain the declarations needed for this article.
What I want to retain is precise: Semigroup supplies a combination rule. Monoid adds a neutral value. foldMap lets me transform inputs into a type whose Monoid can combine the results. The empty batch and the five-item cart make those roles concrete.