Field Notes

Cats Core: ValidatedNec and the Real Difference Between Applicative and Monad

Cats Core: ValidatedNec and the Real Difference Between Applicative and Monad

In part 1 I went from optional fields to validation errors you can actually act on. This part asks why that error collection works at all, and why it stops working the moment one check depends on another.

Most of my day is Java and Spring Boot, where validation means Bean Validation annotations or a hand-rolled list of error messages. I wanted to see how the Scala side handles it, so I wrote a small playground with cats: a Todo with a title and a description, and a validator that tells you everything that is wrong with it.

The interesting part was not the validation itself. It was why the type I used, ValidatedNec, deliberately refuses to give you flatMap. Following that thread is the clearest explanation of Applicative versus Monad I have found.

The playground

Scala 2.13.18, cats-core 2.13.0. The errors are a small ADT:

final case class Todo(title: String, description: String)

sealed trait TodoError { def message: String }

object TodoError {
  case object TitleMissing       extends TodoError { val message = "title missing" }
  case object DescriptionMissing extends TodoError { val message = "description missing" }
  case object TitleToolong       extends TodoError { val message = "title too long" }
}

Each rule is a small function that returns ValidatedNec[TodoError, String], and the full validation combines them with mapN:

def validateTodo(todo: Todo) =
  (validateTitle(todo.title), validateDescriptionMissing(todo.description)).mapN(Todo(_, _))

def validateTitle(title: String): ValidatedNec[TodoError, String] =
  (validateTitleLength(title), validateTitleMissing(title)).mapN((_, _) => title)

def validateTitleLength(title: String): ValidatedNec[TodoError, String] =
  if (title.trim.length > 10) TitleToolong.invalidNec else title.validNec

Running it with a title of 30 spaces and a real description prints exactly one error:

Invalid(Chain(TitleMissing))

It prints Chain, not NonEmptyChain, because NonEmptyChain is a zero-cost newtype over Chain and uses its toString. The non-emptiness lives in the type, not in the runtime object.

Two kinds of box

Here is the distinction that made it click for me.

An Applicative combines boxes that are independent. With (validateTitle(t), validateDescription(d)).mapN(Todo), both boxes exist before anything is combined. Checking the title never needs the description, and the reverse is also true. Because every branch has already run, nothing stops you from combining all the failures. That is what mapN, product and combine do.

A Monad adds flatMap: F[A] and a function A => F[B]. The second box is built from the result of the first. If the first step fails, there is no A to pass in, so step two cannot run. The first error is the only error, by construction. Short-circuiting is not a design choice of Either. It follows from the shape of flatMap.

Two-column comparison. Applicative with mapN: both boxes exist up front, no dependency, all errors combined. Monad with flatMap: the second box is built from the first result, so failure stops the chain.
Accumulating errors requires the steps to be independent. That is the Applicative contract.

Same input, four outcomes

To check this, I ran one bad todo through four combinators. The title is too long and the description is blank:

(title(t), description(d)).mapN(Todo)
// Invalid(Chain(TitleTooLong, DescriptionMissing))

for { a <- title(t).toEither; b <- description(d).toEither } yield Todo(a, b)
// Left(Chain(TitleTooLong))

(title(t).toEither, description(d).toEither).parMapN(Todo)
// Left(Chain(TitleTooLong, DescriptionMissing))

for { a <- title(t); b <- description(d) } yield Todo(a, b)
// error: value flatMap is not a member of cats.data.ValidatedNec[...]

The data is identical each time. Only the combinator changes, and with it the number of errors you get back. The for comprehension over Either desugars to flatMap, so it stops at TitleTooLong. parMapN borrows Validated's applicative through cats' Parallel instance, so it accumulates again. The last one does not compile.

Code figure showing one invalid todo run through mapN, an Either for-comprehension, parMapN, and a for-comprehension on ValidatedNec, with the real output of each.
The combinator decides how many errors you see, not the data.

Why Validated has no Monad

That compile error is deliberate. cats expects a type's ap to agree with the one derived from flatMap. If Validated had a lawful flatMap, it would have to short-circuit, and its mapN would then have to short-circuit as well. You would lose accumulation, which is the whole reason to use the type. So cats gives Validated an Applicative and stops there.

When you really do need a dependent step, you have two exits:

  • andThen on Validated: a flatMap under a different name, there to remind you that you just opted into fail-fast. title("Groceries").andThen(t => t.toLowerCase.validNec) gives Valid(groceries).
  • toEither: switch to Either for the dependent part, then toValidated to come back.

The rule I now apply: use Validated for independent checks on input, such as form fields or request bodies. Use Either for pipelines where step two needs step one, such as parsing an ID and then loading the entity.

Why Nec and not Nel

cats ships both ValidatedNel (NonEmptyList) and ValidatedNec (NonEmptyChain). Accumulating errors means appending the right-hand errors to the left-hand ones, over and over. On a List, appending costs time proportional to the length of the left side. A Chain is a tree of concatenations, so appending is constant time. For a handful of form errors you will never notice. It is still the right default, and it is why cats' own documentation recommends Nec.

An aside: validate the value you keep

Writing this up, I found a small bug in my own playground. validateTitleLength checks title.trim, but validateTitle returns the original title. A title of " short" passes and is stored with its leading spaces. The fix is to have the validator return the normalised value (title.trim.validNec), so the data you checked is the data you keep. This is "parse, don't validate" on a small scale.

Takeaway

The question to ask is not "which type should I use". It is does step two need the result of step one? If not, you have an Applicative and you can keep every error. If it does, you have a Monad and the first failure ends the chain. ValidatedNec just makes the type system enforce the answer.

Keep reading