Search code examples
scalavalidationfunctorapplicativescala-cats

Validation of multiple ADTs with Scala Cats Validated


I am trying to validate a config in scala. First I convert the config json to the respective case class and then I validate it. As I want to fail slow I collect all the validations that fail rather than returning after the first validation that fails. I plan to use applicative functors provided by cats library Cats validation.

The problem I face is the form validation shown in the link works for simple case class

final case class RegistrationData(username: String, password: String, firstName: String, lastName: String, age: Int)

// Below is the code snippet for applying validation from the link   itself
 {
(validateUserName(username),
validatePassword(password),
validateFirstName(firstName),
validateLastName(lastName),
validateAge(age)).mapN(RegistrationData)}

// A more complex case for validations
final case class User(name:String,adds:List[Addresses])
final case class Address(street:String,lds:List[LandMark])
final case class LandMark(wellKnown:Boolean,street:String)

In this case validation on field 'username' is independent of validation on say 'firstName'. But what if the

  1. I had to impose some validation of kind that took both 'firstName' and 'userName' ( say hypothetically Levenstein distance of two should be <= some number ).
  2. case class was not made of simple primitives (Int,String) but had other case classes as its members. eg User case class as mentioned above.

In general is the approach of applicative functors suitable for this case ? Should I even collect all failed validations ?

PS: forgive me if mentioned something incorrectly, I am new to scala.


Solution

  • Based on cats validate example

    import cats.data._
    import cats.data.Validated._
    import cats.implicits._
    
    final case class RegistrationData(name: Name, age: Int, workAge: Int)
    
    final case class Name(firstName: String, lastName: String)
    
    sealed trait DomainValidation {
      def errorMessage: String
    }
    
    case object FirstNameHasSpecialCharacters extends DomainValidation {
      def errorMessage: String =
        "First name cannot contain spaces, numbers or special characters."
    }
    
    case object LastNameHasSpecialCharacters extends DomainValidation {
      def errorMessage: String =
        "Last name cannot contain spaces, numbers or special characters."
    }
    
    case object AgeIsInvalid extends DomainValidation {
      def errorMessage: String =
        "You must be aged 18 and not older than 75 to use our services."
    }
    
    case object AgeIsLessThanWorkInvalid extends DomainValidation {
      def errorMessage: String =
        "You must be aged 18 and not older than 75 to use our services."
    }
    
    sealed trait FormValidatorNec {
    
      type ValidationResult[A] = ValidatedNec[DomainValidation, A]
    
      private def validateFirstName(firstName: String): ValidationResult[String] =
        if (firstName.matches("^[a-zA-Z]+$")) firstName.validNec
        else FirstNameHasSpecialCharacters.invalidNec
    
      private def validateLastName(lastName: String): ValidationResult[String] =
        if (lastName.matches("^[a-zA-Z]+$")) lastName.validNec
        else LastNameHasSpecialCharacters.invalidNec
    
      private def validateAge(age: Int,
                              workAge: Int): ValidationResult[(Int, Int)] = {
        if (age >= 18 && age <= 75 && workAge >= 0)
          if (age > workAge)
            (age, workAge).validNec
          else
            AgeIsLessThanWorkInvalid.invalidNec
        else
          AgeIsInvalid.invalidNec
      }
    
      def validateForm(firstName: String,
                       lastName: String,
                       age: Int,
                       workAge: Int): ValidationResult[RegistrationData] = {
        (
          (validateFirstName(firstName), validateLastName(lastName)).mapN(Name),
          validateAge(age, workAge)
        ).mapN {
          case (n, (a, w)) => RegistrationData(name = n, age = a, workAge = w)
        }
      }
    
    }
    
    object FormValidatorNec extends FormValidatorNec
    
    println(FormValidatorNec.validateForm("firstname", "lastname", 40, 30))
    println(FormValidatorNec.validateForm("firs2tname", "lastname", 20, 30))
    

    Check this fiddle

    Function of mapN is called only when data in the tuple (ValidationResult[_], ValidationResult[_], ...) is Valid. If one or more elements in tuple are Invalid, they are collected into a NotEmtpyChain.

    In summary, all of validate methods are called, and when all them return Valid[_] the mapN function is applied.