Search code examples
scalavalidationscala-cats

Scala-Cats: Validating Map's entries


Let's say I have:

val m: Map[String, Int] = Map("one" -> 1, "five" -> 5, "six" -> 6, "nine" -> 9)

and I have two functions:

def isNotDivisibleByTwo(i: Int): ValidatedNec[String, Int] = Validated.condNec(i%2!=0, i, s"$i is divisible by 2.")

def isNotDivisibleByThree(i: Int): ValidatedNec[String, Int] = Validated.condNec(i%3!=0, i, s"$i is divisible by 3.")

I want a function that gives me:

def sanitize(m: Map[String, Int]):Map[String, Validated[NonEmptyList[String], Int]] = ???

i.e. It should return all the numbers that satisfy the said two functions, and a mapping of all the failing numbers and their associated faults.
e.g. For the given list m, I want to get:

val result = Map(
  "one" -> Valid(1),
  "five -> Valid(5),
  "nine" -> Invalid(NonEmptyList("9 is dividible by 3")),
  "six" -> Invalid(NonEmptyList("6 is dividible by 2", "6 is dividible by 3"))
)

This is what I currently have:

import cats.data._

val m: Map[String, Int] = Map("one" -> 1, "five" -> 5, "six" -> 6, "nine" -> 9)

def isNotDivisibleByTwo(i: Int): ValidatedNec[String, Unit] = Validated.condNec(i%2!=0, (), s"$i is divisible by 2.")

def isNotDivisibleByThree(i: Int): ValidatedNec[String, Unit] = Validated.condNec(i%3!=0, (), s"$i is divisible by 3.")


def sanitize(m: Map[String, Int]): Map[String, Validated[NonEmptyChain[String], Int]] = {

  m.mapValues{
    i =>
      isNotDivisibleByTwo(i).product(
        isNotDivisibleByThree(i)
      ).map(_ => i)
  }

}

But, I am not happy with the way I am "composing" the validations.

How can I do this in the most catsy way?


Solution

  • You were so close.
    Remember that the correct way to combine multiple Validates is using the Applicative syntax.

    import cats.data.{Validated, ValidatedNec}
    import cats.syntax.apply._
    
    type ErrorsOr[A] = ValidatedNec[String, A]
    
    def isNotDivisibleByTwo(i: Int): ErrorsOr[Int] =
      Validated.condNec((i % 2) != 0, i, s"$i is divisible by 2.")
    
    def isNotDivisibleByThree(i: Int): ErrorsOr[Int] =
      Validated.condNec((i % 3) != 0, i, s"$i is divisible by 3.")
    
    val map: Map[String, Int] = Map("one" -> 1, "five" -> 5, "six" -> 6, "nine" -> 9)
    
    def sanitize(m: Map[String, Int]): Map[String, ErrorsOr[Int]] =
      m.view.mapValues { i =>
        (
          isNotDivisibleByTwo(i),
          isNotDivisibleByThree(i)
        ).tupled.map(_ => i)
      }.toMap
    
    sanitize(map)
    // res: Map[String, ErrorsOr[Int]] = Map(
    //   "one" -> Valid(1),
    //   "five" -> Valid(5),
    //   "six" -> Invalid(Append(Singleton("6 is divisible by 2."), Singleton("6 is divisible by 3."))),
    //   "nine" -> Invalid(Singleton("9 is divisible by 3."))
    // )
    

    However, you may make the code even more general, to work with any number of validations. By using traverse.
    (In this case, you do not need any syntax import).

    import cats.data.NonEmptyList
    
    val validations: NonEmptyList[Int => ErrorsOr[Int]] = NonEmptyList.of(isNotDivisibleByTwo, isNotDivisibleByThree)
    
    def sanitize[K, V](map: Map[K, V])
                      (validations: NonEmptyList[V => ErrorsOr[V]]): Map[K, ErrorsOr[V]] =
      map.view.mapValues(i => validations.traverse(f => f(i)).map(_ => i)).toMap
    
    sanitize(map)(validations)
    // res: Map[String, ErrorsOr[Int]] = Map(
    //   "one" -> Valid(1),
    //   "five" -> Valid(5),
    //   "six" -> Invalid(Append(Singleton("6 is divisible by 2."), Singleton("6 is divisible by 3."))),
    //   "nine" -> Invalid(Singleton("9 is divisible by 3."))
    // )
    

    The reason why I use .view.mapValues(...).toMap is because on Scala 2.13 mapValues is deprecated.