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!
Subscribe to my newsletter
Read articles from Bondarenko directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Bondarenko
Bondarenko
Senior Scala developer