Cats Core, Part 1: From Optional Fields to Useful Validation Errors
I wanted my first Cats example to solve a problem I could explain without drawing a type-class hierarchy. Coming from Java and Spring, I had already worked through Scala generics and implicit arguments. Repeating that machinery was not answering the question I cared about: what does Cats actually help me write?
I used a small insurance-policy form: a policy number and an annual premium. Both fields might be absent. First I combined them with Option. Then I kept the same combination operation and changed the result type to preserve useful errors. This is the starting point of my Cats Core series: a concrete requirement, a runnable example, and the abstraction when it earns its place.
A small, reproducible setup
My lab uses Scala 2.13.18, sbt 1.12.15, and Cats Core 2.13.0. The Scala and Cats version numbers belong to separate projects. In build.sbt:
ThisBuild / scalaVersion := "2.13.18"
libraryDependencies += "org.typelevel" %% "cats-core" % "2.13.0"
In project/build.properties, I pin sbt.version=1.12.15. With those files, sbt console starts a REPL where the following Scala snippets can be evaluated in order. The imports and policy definition are:
import cats.data.Validated
import cats.syntax.all._
case class Policy(number: String, annualPremium: BigDecimal)
Two optional fields, one policy
The inputs already exist independently. Obtaining the premium does not require extracting the policy number first. Their types make absence explicit:
val number: Option[String] = Some("POL-001")
val premium: Option[BigDecimal] = Some(BigDecimal("600"))
A standard Scala for comprehension handles this correctly. It produces a policy only when both values are present:
val withScala: Option[Policy] =
for {
n <- number
p <- premium
} yield Policy(n, p)
Cats supplies mapN on the tuple of inputs. I give it the ordinary two-argument construction function:
val withCats: Option[Policy] =
(number, premium).mapN { (n, p) =>
Policy(n, p)
}
// Some(Policy(POL-001,600))
Inside the lambda, n is a String and p is a BigDecimal. The lambda returns a plain Policy; the complete expression returns Option[Policy]. When an input is None, the result is None and the construction function is not called.

The shorter form is (number, premium).mapN(Policy.apply). For this small example, the for comprehension remains a reasonable choice. I use mapN because it states the combination of independent inputs directly, and because the operation remains useful when the surrounding type changes. Independence here describes data dependencies; it does not promise parallel execution.
None cannot explain the failed submission
If a user leaves both fields empty, None tells me that I could not construct the policy. It does not contain the two explanations I would want to display on a form. I could invent a result class and manually gather messages, but Cats already has a result type designed for this kind of combination.
Validated[E, A] holds either Valid(value) or Invalid(errors). I use List[String] for the error type and keep the successful value specific to each field. The number check returns Validated[List[String], String]; the premium check returns Validated[List[String], BigDecimal].
Write the checks, then combine their results
My illustrative rules are deliberately small: the policy number must contain a non-whitespace character, and the annual premium must be positive. These are learning rules, not a claim about a production insurance contract. Each field check reports at most one error.
def validateNumber(number: Option[String]): Validated[List[String], String] =
number match {
case None =>
Validated.Invalid(List("Policy number is missing"))
case Some(value) if value.trim.isEmpty =>
Validated.Invalid(List("Policy number must not be blank"))
case Some(value) =>
Validated.Valid(value)
}
def validatePremium(premium: Option[BigDecimal]): Validated[List[String], BigDecimal] =
premium match {
case None =>
Validated.Invalid(List("Annual premium is missing"))
case Some(value) if value <= 0 =>
Validated.Invalid(List("Annual premium must be positive"))
case Some(value) =>
Validated.Valid(value)
}
Validated.Valid does not validate anything by itself. My code checks the condition and chooses the result. Cats handles combining the returned results:
def buildPolicy(
number: Option[String],
premium: Option[BigDecimal]
): Validated[List[String], Policy] = {
val checkedNumber = validateNumber(number)
val checkedPremium = validatePremium(premium)
(checkedNumber, checkedPremium).mapN(Policy.apply)
}
Both field checks run before the results are combined. When both succeed, mapN calls the constructor and returns Valid(policy). When either fails, the failure carries its messages. When both fail, Cats joins the two error lists in input order:
buildPolicy(number, premium)
// Valid(Policy(POL-001,600))
buildPolicy(None, None)
// Invalid(List(Policy number is missing, Annual premium is missing))
buildPolicy(Some(""), Some(BigDecimal("-10")))
// Invalid(List(Policy number must not be blank, Annual premium must be positive))

I chose an ordinary list to make the error values easy to inspect. A list can technically be empty; my invalid branches always supply a message. Also, accumulation covers the errors my check functions actually return. Cats does not invent extra checks or discover domain rules.
What I verified
I ran the lab with sbt lesson04 lesson05. The Option lesson compares the Scala comprehension, the Cats lambda, and the constructor shorthand across all four presence/absence combinations: twelve assertions. The Validated lesson checks seven outcomes, including whitespace-only numbers, zero premiums, and two failures together. All passed.
One compact assertion captures the behavior that motivated the change:
assert(
buildPolicy(None, None) == Validated.Invalid(
List("Policy number is missing", "Annual premium is missing")
)
)
The reusable idea is now concrete: combine independent inputs with an ordinary function, while the surrounding result type determines what happens when an input is unavailable or invalid. My next step is to examine how Cats knows how to combine those error lists. The type-class vocabulary will have an observed behavior to explain.
References: Typelevel’s mapN introduction, Validated, and Scala 2.13.18.