Cats Core, Part 2: Teaching Cats How to Combine Our Validation Errors
In Part 1, I used mapN to combine independent policy-field checks. Two failures produced two useful messages. I could follow the result, but I was still missing a step: where did Validated actually use a Semigroup?
Seeing the method call helped more than another definition. I first exposed the helper passed to Cats, then replaced the raw error list with a type that retains field names. This post follows those two changes, using the same small policy example.
Make the combination helper visible
The lab remains on Scala 2.13.18, Cats Core 2.13.0, and sbt 1.12.15. With the Part 1 build configuration, the executable snippets below can be evaluated in order in sbt console. The example starts with two already-computed failures; the field-checking functions are covered in Part 1.
import cats.Semigroup
import cats.data.Validated
import cats.syntax.all._
case class Policy(number: String, annualPremium: BigDecimal)
val numberCheck: Validated[List[String], String] =
Validated.Invalid(List("Policy number is missing"))
val premiumCheck: Validated[List[String], BigDecimal] =
Validated.Invalid(List("Annual premium is missing"))
I can supply Cats’ list-combination object explicitly to product:
val listCombiner: Semigroup[List[String]] =
Semigroup[List[String]]
val paired: Validated[List[String], (String, BigDecimal)] =
numberCheck.product(premiumCheck)(listCombiner)
val explicitPolicy: Validated[List[String], Policy] =
paired.map { case (number, premium) => Policy(number, premium) }
val viaMapN: Validated[List[String], Policy] =
(numberCheck, premiumCheck).mapN(Policy.apply)
assert(explicitPolicy == viaMapN)
product pairs successful values. On two failures it calls the supplied helper’s combine method, then wraps the combined errors in Invalid. A single failure is retained. The following map constructs a policy only for a successful pair. This explicit sequence matches our two-input mapN result; it is not a literal compiler rewrite.
The two-failure branch in Cats 2.13.0 is small. With names expanded for readability, the fragment is:
case (Validated.Invalid(leftErrors), Validated.Invalid(rightErrors)) =>
Validated.Invalid(errorCombiner.combine(leftErrors, rightErrors))
With mapN, the compiler resolves Cats’ combination instance for Validated. That instance requires Semigroup[E], where E is the error type. Here it is List[String]. Cats supplies the list instance, which concatenates the lists. My plain listCombiner variable is used by the explicit call; its name does not make it an implicit candidate.

Give the form errors an application type
For the next example, I want each message to identify the field that needs attention. That gives a caller enough information to associate an error with an input. I define a field error and a collection of those errors:
case class FieldError(field: String, message: String)
case class ValidationErrors(items: List[FieldError])
val numberErrors = ValidationErrors(
List(FieldError("number", "is missing"))
)
val premiumErrors = ValidationErrors(
List(FieldError("annualPremium", "must be positive"))
)
The error type is now ValidationErrors. Although it contains a list, it is a separate type. I need to supply its combination rule. The case class describes the data; the following object describes how two values of that data type combine.
implicit val validationErrorsSemigroup: Semigroup[ValidationErrors] =
new Semigroup[ValidationErrors] {
override def combine(
left: ValidationErrors,
right: ValidationErrors
): ValidationErrors =
ValidationErrors(left.items ++ right.items)
}
I chose to keep the left items followed by the right items. This preserves field names, messages, order, and duplicates. The implementation neither validates a field nor deduplicates errors. Its implicit declaration makes the rule available in the scope of the example. The error data class does not need to extend a Cats interface.
Use the same mapN with the new error type
val checkedNumber: Validated[ValidationErrors, String] =
Validated.Invalid(numberErrors)
val checkedPremium: Validated[ValidationErrors, BigDecimal] =
Validated.Invalid(premiumErrors)
val result: Validated[ValidationErrors, Policy] =
(checkedNumber, checkedPremium).mapN(Policy.apply)
val expected = ValidationErrors(List(
FieldError("number", "is missing"),
FieldError("annualPremium", "must be positive")
))
assert(result == Validated.Invalid(expected))
Now E is ValidationErrors, so my implicit instance supplies the helper. When both checks fail, Cats invokes my combine implementation with their error values. The result is one Invalid holding both structured errors. I changed the error model and supplied its rule while keeping the same policy-construction expression.
The explicit form still works and makes that exact helper visible:
val explicitCustom: Validated[ValidationErrors, Policy] =
checkedNumber.product(checkedPremium)(validationErrorsSemigroup)
.map { case (number, premium) => Policy(number, premium) }
assert(explicitCustom == result)
My model permits an empty items list. In these examples, every failure contains an error, but the type itself does not enforce that invariant. Field names are ordinary strings too. Those are deliberate limits of this small model, rather than guarantees supplied by Cats.
Explain why this rule is associative
A Semigroup requires an associative operation: regrouping three inputs must preserve the result. For my implementation, the underlying item lists become either (a.items ++ b.items) ++ c.items or a.items ++ (b.items ++ c.items). List concatenation preserves the same ordered items in both cases. Wrapping that list preserves equality, so the argument applies to any three error reports.
val customerErrors = ValidationErrors(
List(FieldError("customerName", "is missing"))
)
val combineErrors = validationErrorsSemigroup
val groupedLeft = combineErrors.combine(
combineErrors.combine(numberErrors, premiumErrors), customerErrors
)
val groupedRight = combineErrors.combine(
numberErrors, combineErrors.combine(premiumErrors, customerErrors)
)
assert(groupedLeft == groupedRight)

This assertion illustrates the law for one triple; the reasoning about list concatenation explains it generally. Swapping two reports can change the order, which is allowed: associativity does not require commutativity. The compiler checks the implementation’s types, but it does not prove that an arbitrary combine method obeys the law.
What I verified
sbt lesson06 lesson07 runs both examples in the learning repository. Lesson 06 has six checks, including equivalence between mapN and explicit product followed by map. Lesson 07 has eight: direct combination, two failures, the explicit equivalent, each single failure, two successes, and both groupings of three reports. All passed. I also compiled the article’s executable snippets together; the isolated pattern-match fragment above is explanatory.
The distinction I wanted is now visible in code. My field checks decide which values fail. Validated handles the combination of successes and failures. My Semigroup supplies the operation for combining the contents of two failures. That division lets me change the error model without rewriting the surrounding validation flow.