Writing simple validator with Scala 3

Photo by Scott Webb on Unsplash

Writing simple validator with Scala 3

In my previous article we discussed some approaches to validating using Cats and Scala 3 mirror types. In this article I shed light on how to write a simple validation library using Scala 3 metaprogramming.

Let's say we have a case class Planet :

case class Planet(planetName: String, diameter: Int, sType: String)

The validator in the following form:

import cats.data.ValidatedNec
import example.validator.Validator.ErrorsOr

trait Validator[A] {
  def validate(x: A): ErrorsOr[A]
}

object Validator:
  type ErrorsOr[A] = ValidatedNec[String, A]

Our objective is to create Validator[Planet] using validator instances for fields. The desired syntax for constructing a validator is as follows:

      val v: Validator[Planet] =
        validator[Planet].withValidators(
          withValidator(_.planetName, nonEmptyValidatorValidator),
          withValidator(_.diameter, diameterValidator)
        )

For missed fields (sType in this example) a compiler should generate a default 'always true' validator.

where field validators are defined below:

  val nonEmptyValidatorValidator: Validator[String] =
    (x: String) =>
      Validated.cond(
        !x.isBlank,
        x,
        NonEmptyChain.one(s"{} cannot be empty")
      )

  val diameterValidator: Validator[Int] = (x: Int) =>
    Validated.cond(x > 1000, x, NonEmptyChain.one("{} is too small"))

Implementation

Let's define ValidatorBuilder class and Validators object

case class ValidatorBuilder[A]() {
  import example.validator.internal.Transformations.*
  inline def withValidators(inline config: BuilderConfig[A]*)(using
      product: Mirror.ProductOf[A]
  ): Validator[A] =
    Transformations.withValidators(config*)(product)
}

object Validators {
  def validator[A]: ValidatorBuilder[A] = ValidatorBuilder()
  @compileTimeOnly(
    "'withValidator' needs to be erased from the AST with a macro."
  )
  def withValidator[Source, FieldType, ActualType](
      selector: Source => ActualType,
      v: Validator[ActualType]
  )(using
      ev1: ActualType <:< FieldType,
      @implicitNotFound(
        "Field validator is supported for product types only, but ${Source} is not a product type."
      )
      ev2: Mirror.ProductOf[Source]
  ): BuilderConfig[Source] = throw NotQuotedException("Field validator")
}

product: Mirror.ProductOf[A] is necessary as we are going to deal with A type as product type.

withValidator method accept a field selector and a field validator. It's worth to mention this method isn't called in runtime, it's needed only at compile time to construnct a target Validator[A] instance.

All compile time magic occures here:

  inline def withValidators(inline config: BuilderConfig[A]*)(using
      product: Mirror.ProductOf[A]
  ): Validator[A] =
    Transformations.withValidators(config*)(product)

Where Validator[A] instance is constructed from sequense of withValidator calls (not actually calls).

Here is the implementation of Transformations.withValidators:

  inline def withValidators[A](inline config: BuilderConfig[A]*)(
      inline product: Mirror.ProductOf[A]
  ): Validator[A] =
    ${ transformConfiguredMacro[A]('config, 'product) }

Starting from this I'd recommend to read about splices and quotes in Scala 3. In nutshell, quote converts a code block to Expr , splice $ converts Expr representing the code back.

transformConfiguredMacro method signature looks as follows:

  private def transformConfiguredMacro[Source: Type](
      config: Expr[Seq[BuilderConfig[Source]]],
      product: Expr[Mirror.ProductOf[Source]]
  )(using Quotes): Expr[Validator[Source]]

The method works with Exprs only as we can see. First argument config provides us field to validator mappings (which field name to which validator is mapped), product argument provides a metadata (field types and labels first of all) for target type A (Planet case class in our example).

Here is the implementation of this method (the most complicated part actually):

  private def transformConfiguredMacro[Source: Type](
      config: Expr[Seq[BuilderConfig[Source]]],
      product: Expr[Mirror.ProductOf[Source]]
  )(using Quotes): Expr[Validator[Source]] = {
    given source: Fields.Source = Fields.Source.fromMirror(product)


    val defaultValidator: Expr[Validator[Any]] = '{ (x: Any) =>
      x.validNec[String]
    }

    val validatorsExprs =
      parseConfig(config)(_.flatMap(parseSingleProductConfig))

    val validatorsMap =
      validatorsExprs.map(p => p.fieldName -> p.validator).toMap

    val orderedValidators = source.value.map(f =>
      validatorsMap.applyOrElse(f.name, _ => defaultValidator)
    )

    val tupleExpr = Expr.ofTupleFromSeq(orderedValidators.toSeq)

    '{
      new Validator[Source] {
        override def validate(x: Source): ErrorsOr[Source] = {
          def enrichErrorMessage(fieldName: String)(e: ErrorsOr[Any]): ErrorsOr[Any] =
            e.leftMap(errors => errors.map(s => s.replace("{}", fieldName)))

          val elems = x.asInstanceOf[Product].productIterator
          val fieldNames = x.asInstanceOf[Product].productElementNames
          val validators = ${ tupleExpr }.asInstanceOf[Product].productIterator
          val allErrors = validators.zip(elems).zip(fieldNames).map { case ((validator, elem), fieldName) =>
            enrichErrorMessage(fieldName)(validator.asInstanceOf[Validator[Any]].validate(elem))
          }

          val combinedErrors =
            SemigroupK[NonEmptyChain].combineAllOptionK(allErrors.collect {
              case Invalid(e) => e
            })
          combinedErrors match
            case Some(errors) => Invalid(errors)
            case _            => x.validNec

        }
      }
    }

  }

defaultValidator here refers to 'always true' validator which is used when no explicit validators defined for certain field.

    given source: Fields.Source = Fields.Source.fromMirror(product)

This is actually a list of fields extracted from target product type.

validatorsExprs is a list of pairs fieldName -> Expr[Validator] . It's essential for constructing target instance Validator[A] .

Next method worth mentioning is parseSingleProductConfig with the following signature:

  private def parseSingleProductConfig[Source](
      config: Expr[BuilderConfig[Source]]
  )(using
      Quotes,
      Fields.Source
  ): List[MaterializedConfiguration.Product.FieldValidator]

Which converts compile tyme entries like withValidator(_.planetName, nonEmptyValidatorValidator) in pairs fieldName -> Expr[Validator]. The implementation details can be seen here.

    val orderedValidators = source.value.map(f =>
      validatorsMap.applyOrElse(f.name, _ => defaultValidator)
    )

Here we use product's metadata in order to get List[Expr[Validator[Any]]] where the order of fields is the same as in target product type so this list can be more easily converted to target validator instance Validator[A] .

val tupleExpr = Expr.ofTupleFromSeq(orderedValidators)

Here we just converted List[Expr[Validator[Any]]] to Expr[Tuple[_, _, ...]] .

Now we need to construct an expression for target instance Expr[Validator[A]] using Expr[Tuple[_, _, ...]] . Here how we did it:

    '{
      new Validator[Source] {
        override def validate(x: Source): ErrorsOr[Source] = {
          def enrichErrorMessage(fieldName: String)(e: ErrorsOr[Any]): ErrorsOr[Any] =
            e.leftMap(errors => errors.map(s => s.replace("{}", fieldName)))

          val elems = x.asInstanceOf[Product].productIterator
          val fieldNames = x.asInstanceOf[Product].productElementNames
          val validators = ${ tupleExpr }.asInstanceOf[Product].productIterator
          val allErrors = validators.zip(elems).zip(fieldNames).map { case ((validator, elem), fieldName) =>
            enrichErrorMessage(fieldName)(validator.asInstanceOf[Validator[Any]].validate(elem))
          }

          val combinedErrors =
            SemigroupK[NonEmptyChain].combineAllOptionK(allErrors.collect {
              case Invalid(e) => e
            })
          combinedErrors match
            case Some(errors) => Invalid(errors)
            case _            => x.validNec

        }
      }
    }

That's it. The validator is ready!

There are some points of improvements though:

  • Make it configurable to support fail fast strategy also. The provided one is accumulative.

  • Allow the validator to use other error types beyond Validated[NonEmptyChain[E], A]

  • The validator depends excessively on Cats types which is not good. Ideally it shouldn't depend on any library in order to have more flexibility.

Full source code can be found in GitHub repository.

Comments and pull requests for improvements/suggestions are appreciated!