Photo by Clément Hélardot on Unsplash
Validating in Scala
Some validation approaches examples using Scala3 and Cats
Let's talk about validating in Scala in a functional programming way.
Consider the following scenario: you are working on a project connected to finding exoplanets in the habitable zone. One of your tasks is writing an accumulative validator for planets like the following:
trait Validator[A]:
def validate(x: A): ValidatedNec[String, A]
Let's define Planet
case class:
case class Planet(name: String, diameter: Double)
The validator should validate the following:
planet's name isn't empty
the diameter is in certain bounds
Our desired outcome:
planetValidator.validate(
Planet("", 1.0)
) // Invalid(Chain(string cannot be empty, [1.0]: Value cannot be lower than 4878.0))
planetValidator.validate(
Planet("Proxima b", 12000)
) // Valid(Planet(Proxima b,12000.0))
Let's try.
Here are the imports we are using:
import cats.syntax.all.*
import cats.implicits.*
import cats.{Apply, Invariant, Semigroup, SemigroupK, Semigroupal}
import cats.data.{NonEmptyChain, Validated, ValidatedNec}
import cats.data.Validated.{Invalid, Valid}
import math.Numeric.Implicits.infixNumericOps
import scala.math.Ordered.orderingToOrdered
Type alias to avoid a lot of code:
type ErrorOr[A] = ValidatedNec[String, A]
Defining lower and upper bound validators:
def lowerBoundValidator[A: Numeric](lowerBound: A): Validator[A] = entity =>
Validated.cond(
entity >= lowerBound,
entity,
NonEmptyChain.one(s"[$entity]: Value cannot be lower than $lowerBound")
)
def upperBoundValidator[A: Numeric](upperBound: A): Validator[A] =
entity =>
Validated.cond(
entity <= upperBound,
entity,
NonEmptyChain.one(
s"[$entity]: Value cannot be greater than $upperBound"
)
)
As we don't know which numeric type to use for the planet's diameter, we defined the validator for any numeric type by using context-bound A: Numeric
.
Name validator:
lazy val nonEmptyStringValidator: Validator[String] = entity =>
Validated.cond(
!entity.isBlank,
entity,
NonEmptyChain.one("string cannot be empty")
)
Combining all validators together with Cats type classes
Our goal is to combine lowerBoundValidator
and upperBoundValidator
validators, then create Validator[Planet]
using all defined earlier validators.
Here we just provide lower and upper bounds for the validators:
// we are interested in planets with diameter between Mercury's and Earth's
lazy val diameterLowerBoundValidator = lowerBoundValidator(
2439.0 * 2
) // Mercury diameter
lazy val diameterUpperBoundValidator = upperBoundValidator(
6378.0 * 2
) // Earth diameter
Let's combine two validators using SemigroupK
:
// combining Validated with SemigroupK
// here we need only SemigroupK[Validated]
// witch is provided for us out the box by magic import cats.syntax.all.*
lazy val diameterValidator1: Validator[Double] = (diameter: Double) =>
diameterLowerBoundValidator.validate(
diameter
) <+> diameterUpperBoundValidator.validate(diameter)
Another way to do it is by implementing SemigroupK[Validator]
:
// combining two validators of the same type to one validator with SemigroupK
// here we need SemigroupK[Validator] instance which should be defined by a developer
lazy val diameterValidator2: Validator[Double] =
semigroupK.combineK(
diameterLowerBoundValidator,
diameterUpperBoundValidator
)
To avoid boilerplate code like this we use cats-tagless
to auto-generate SemigroupK[Validator]
instance like in this example.
As Cats-tagless isn't fully migrated to Scala3, we cannot use it here. So let's define our own SemigroupK[Validator]
instance:
lazy val semigroupK: SemigroupK[Validator] = new SemigroupK[Validator]:
override def combineK[A](v1: Validator[A], v2: Validator[A]): Validator[A] =
(x: A) => SemigroupK[ErrorOr].combineK(v1.validate(x), v2.validate(x))
But we have an issue with diameter validators: both diameterValidator1
and diameterValidator2
are not accumulative validators. SemigroupK[ErrorOr].combineK
returns the first valid value like in the following snippet:
@ SemigroupK[ErrorOr].combineK("error".invalidNec[Int], 0.validNec[String])
res0: Validated[Type[String], Int] = Valid(a = 0)
So we need another approach. Apply
type class can be useful here:
// accumulative error validator
lazy val diameterValidator3: Validator[Double] = (x: Double) =>
Apply[ErrorOr].productL(
diameterLowerBoundValidator.validate(x)
)(diameterUpperBoundValidator.validate(x))
Apply[ErrorOr]
instance is provided by cats for us.
Now we need to combine nonEmptyStringValidator
and diameterValidator
in order to create Validator[Planet]
For this purpose, we should define Invariant[Validator]
and Semigroupal[Validator]
type classes (or use cats-tagless in case we are on Scala2).
given invariant: Invariant[Validator] with
override def imap[A, B](fa: Validator[A])(f: A => B)(
g: B => A
): Validator[B] = (x: B) => fa.validate(g(x)).map(f)
given semigroupal: Semigroupal[Validator] with
override def product[A, B](fa: Validator[A], fb: Validator[B]) =
(x: (A, B)) =>
Semigroupal[ErrorOr] // Semigroupal[ErrorOr] is provided by cats
.product(fa.validate(x._1), fb.validate(x._2))
And finally, we can define our planet validator:
// imapN requires Invariant[Validator] and Semigroupal[Validator] instances which were provided earlier
lazy val planetValidator: Validator[Planet] =
(nonEmptyStringValidator, diameterValidator3).imapN(Planet.apply)(
Tuple.fromProductTyped(_)
)
That's it.
planetValidator.validate(
Planet("", 1.0)
) // Invalid(Chain(string cannot be empty, [1.0]: Value cannot be lower than 4878.0))
planetValidator.validate(
Planet("Proxima b", 12000)
) // Valid(Planet(Proxima b,12000.0))
Complete code can be found here.
Combining validators using Scala3 mirror types
For more about mirror types in Scala3 please refer to this article.
Assume we have defined our field validators as givens:
given nameValidator: Validator[String] = (x: String) =>
Validated.cond(
x.length < 10,
x,
NonEmptyChain("Planet name should not be long")
)
given diameterValidator: Validator[Double] = (x: Double) =>
Validated.cond(x > 0, x, NonEmptyChain("Diameter should be positive"))
And we don't want to combine them together with imapN
like this:
(nonEmptyStringValidator, diameterValidator3).imapN(Planet.apply)(
Tuple.fromProductTyped(_)
)
We want the compiler to provide us with the planet validator like here:
val validator = summon[Validator[Planet]]
To make it possible let's do the following:
import scala.deriving.Mirror
import cats.data.ValidatedNec
object Validator:
type ErrorsOr[A] = ValidatedNec[String, A]
inline def derived[A](using m: Mirror.ProductOf[A]): Validator[A] = ???
The implementation of derived
method is here.
Now we should redefine Planet case class like the following:
case class Planet(name: String, diameter: Double) derives Validator
And that's it. Now Validator[Planet]
is provided by the compiler.
For those who follow single responsibility principle
and don't want to mix data classes with business logic like validation, I'd recommend calling derived
explicitly:
val validator = Validator.derived[Planet]
The whole example is here.
If you are interested in how to write your own validation library with Scala 3 please take a look here.