Writing validation rules for the complex data structure is an everyday business developer's life. There are multiple ways available in Scala to do the validation but still one of the simplest ways to represent and manipulate validation results is to use the built-in Either.
This library provides a thin wrapper around Either with a simpler API and opinionated type parameters.
Here the validator is represented by the function type alias:
sealed trait Error
type Result = Either[Error, Unit]
type Validate[-T] = T => Result
The rest of the API is focused on creating and composing instances of Validate[T].
- Scala >= 3.3.6
Use with SBT
libraryDependencies += "org.encalmo" %% "validator" % "0.9.0"
or with SCALA-CLI
//> using dep org.encalmo::validator:0.9.0
importcom.github.arturopala.validator.Validator._caseclassAddress(
street: String,
town: String,
postcode: String,
country: String
)
caseclassPhoneNumber(
prefix: String, number: String, description: String
)
caseclassContact(
name: String,
address: Either[String, Address],
phoneNumbers: Seq[PhoneNumber] =Seq.empty,
email: Option[String] =None
)
objectCountry {
valcodes=Set("en", "de", "fr")
valtelephonePrefixes=Set("+44", "+41", "+42")
}
valvalidatePostcode=
checkIsTrue[String](_.matches("""\d{5}"""), "address.postcode.invalid")
// validatePostcode: Validate[String] = com.github.arturopala.validator.Validator$$$Lambda$9449/0x000000080269c840@72e2046cvalvalidateCountry=
checkIsTrue[String](_.isOneOf(Country.codes), "address.country.invalid")
// validateCountry: Validate[String] = com.github.arturopala.validator.Validator$$$Lambda$9449/0x000000080269c840@be576fevalvalidatePhoneNumberPrefix=
checkIsTrue[String](
_.isOneOf(Country.telephonePrefixes),
"address.phone.prefix.invalid"
)
// validatePhoneNumberPrefix: Validate[String] = com.github.arturopala.validator.Validator$$$Lambda$9449/0x000000080269c840@5ff82116valvalidatePhoneNumberValue=
checkIsTrue[String](_.matches("""\d{7}"""), "address.phone.prefix.invalid")
// validatePhoneNumberValue: Validate[String] = com.github.arturopala.validator.Validator$$$Lambda$9449/0x000000080269c840@7480a87avalvalidatePhoneNumber=
all[PhoneNumber](
checkProp(_.prefix, validatePhoneNumberPrefix),
checkProp(_.number, validatePhoneNumberValue)
)
// validatePhoneNumber: PhoneNumber => Either[Error, Unit] = com.github.arturopala.validator.Validator$$$Lambda$9451/0x000000080269f040@294801f5valvalidateEmail= all[String](
checkIsTrue[String](_.contains("@"), "address.email.invalid")
)
// validateEmail: Validate[String] = com.github.arturopala.validator.Validator$$$Lambda$9451/0x000000080269f040@9bac0a4valvalidateAddress=
all[Address](
checkIsTrue(_.street.nonEmpty, "address.street.empty"),
checkIsTrue(_.town.nonEmpty, "address.town.empty"),
checkProp(_.postcode, validatePostcode),
checkProp(_.country, validateCountry)
)
// validateAddress: Address => Either[Error, Unit] = com.github.arturopala.validator.Validator$$$Lambda$9451/0x000000080269f040@1754a014valvalidateContact=
all[Contact](
checkIsTrue(_.name.nonEmpty, "contact.name.empty"),
checkEither(_.address, validateStringNonEmpty("address.manual.empty") , validateAddress),
any(
checkIfSome(_.email, validateEmail, isValidIfNone =false),
all(
checkProp(_.phoneNumbers, validateCollectionNonEmpty("address.phoneNumbers.empty")),
checkEach(_.phoneNumbers, validatePhoneNumber)
)
)
)
// validateContact: Contact => Either[Error, Unit] = com.github.arturopala.validator.Validator$$$Lambda$9451/0x000000080269f040@666619fc// TESTvalc1=Contact(
name ="Foo Bar",
address =Right(Address(
street ="Sesame Street 1",
town ="Cookieburgh",
country ="en",
postcode ="00001"
)),
phoneNumbers =Seq(
PhoneNumber("+44", "1234567", "ceo"),
PhoneNumber("+41", "7654321", "sales")
)
)
// c1: Contact = Contact(// name = "Foo Bar",// address = Right(// value = Address(// street = "Sesame Street 1",// town = "Cookieburgh",// postcode = "00001",// country = "en"// )// ),// phoneNumbers = List(// PhoneNumber(prefix = "+44", number = "1234567", description = "ceo"),// PhoneNumber(prefix = "+41", number = "7654321", description = "sales")// ),// email = None// )
validateContact(c1)
// res0: Either[Error, Unit] = Right(value = ())valc2=Contact(
name ="",
address =Right(Address(street ="", town ="", country ="ca", postcode ="foobar")),
phoneNumbers =Seq(
PhoneNumber("+1", "11111111111", "ceo"),
PhoneNumber("+01", "00000000", "sales")
)
)
// c2: Contact = Contact(// name = "",// address = Right(// value = Address(street = "", town = "", postcode = "foobar", country = "ca")// ),// phoneNumbers = List(// PhoneNumber(prefix = "+1", number = "11111111111", description = "ceo"),// PhoneNumber(prefix = "+01", number = "00000000", description = "sales")// ),// email = None// )
validateContact(c2).isValid
// res1: Boolean = false
validateContact(c2).errorsOption
// res2: Option[Seq[String]] = Some(// value = List(// "contact.name.empty",// "address.street.empty",// "address.town.empty",// "address.postcode.invalid",// "address.country.invalid",// "Expected Some value but got None",// "address.phone.prefix.invalid"// )// )valc3=Contact(
name ="Alice",
address =Left("1 Home Av. Daisytown CA"),
email =Some("alice@home")
)
// c3: Contact = Contact(// name = "Alice",// address = Left(value = "1 Home Av. Daisytown CA"),// phoneNumbers = List(),// email = Some(value = "alice@home")// )
validateContact(c3).isValid
// res3: Boolean = true
validateContact(c3).errorsOption
// res4: Option[Seq[String]] = Nonevalc4=Contact(
name ="",
address =Left(""),
email =Some("alice.home")
)
// c4: Contact = Contact(// name = "",// address = Left(value = ""),// phoneNumbers = List(),// email = Some(value = "alice.home")// )
validateContact(c4).isValid
// res5: Boolean = false
validateContact(c4).errorsOption
// res6: Option[Seq[String]] = Some(// value = List(// "contact.name.empty",// "address.manual.empty",// "address.email.invalid",// "address.phoneNumbers.empty"// )// )importcom.github.arturopala.validator.Validator._caseclassE(a: Int, b: String, c: Option[Int], d: Seq[Int], e: Either[String,E], f: Option[Seq[Int]], g: Boolean, h: Option[String])
valdivisibleByThree= checkIsTrue[Int](_ %3==0, "must be divisible by three")
// divisibleByThree: Validate[Int] = com.github.arturopala.validator.Validator$$$Lambda$9449/0x000000080269c840@5a560ad9valvalidateE:Validate[E] = any[E](
checkEquals(_.a.toString, _.b, "a must be same as b"),
checkNotEquals(_.a.toString, _.b, "a must be different to b"),
checkFromEither(_.e),
checkIsDefined(_.c, "c must be defined"),
checkIsEmpty(_.c, "c must be not defined"),
checkWith(_.a, divisibleByThree),
checkIfSome(_.c, divisibleByThree, isValidIfNone =true),
all(
checkIfSome(_.c, divisibleByThree, isValidIfNone =false),
checkEach(_.d, divisibleByThree),
checkEachIfNonEmpty(_.d, divisibleByThree),
checkEachIfSome(_.f, divisibleByThree),
),
checkIfAllDefined(Seq(_.c, _.f),"c and f must be all defined"),
checkIfAllEmpty(Seq(_.c, _.f),"c and f must be all empty"),
checkIfAllOrNoneDefined(Seq(_.c, _.f),"c and f must be either all defined or all empty"),
checkIfAtLeastOneIsDefined(Seq(_.c,_.f),"c or f or both must be defined"),
checkIfAtMostOneIsDefined(Seq(_.c,_.f),"none or c or f must be defined"),
checkIfOnlyOneIsDefined(Seq(_.c,_.f),"c or f must be defined"),
checkIfOnlyOneSetIsDefined[E](Seq(Set(_.c,_.f), Set(_.c,_.h)),"only (c and f) or (c and h) must be defined"),
checkIfAllTrue(Seq(_.a.inRange(0,10), _.g),"a must be 0..10 if g is true"),
checkIfAllFalse(Seq(_.a.inRange(0,10), _.g),"a must not be 0..10 if g is false"),
checkIfAtLeastOneIsTrue(Seq(_.a.inRange(0,10), _.g),"a must not be 0..10 or g or both must be true"),
checkIfAtMostOneIsTrue(Seq(_.a.inRange(0,10), _.g),"none or a must not be 0..10 or g must be true"),
checkIfOnlyOneIsTrue(Seq(_.a.inRange(0,10), _.g),"a must not be 0..10 or g must be true"),
checkIfOnlyOneSetIsTrue[E](Seq(Set(_.a.inRange(0,10), _.g), Set(_.g,_.h.isDefined)),"only (g and a must not be 0..10) or (g and h.isDefined) must be true"),
)
// validateE: Validate[E] = com.github.arturopala.validator.Validator$$$Lambda$9457/0x00000008026ab840@bba516dCreate a simple validator:
importcom.github.arturopala.validator.Validator._valvalidateIsEven:Validate[Int] = checkIsTrue[Int](_ %2==0, "must be even integer")
valvalidateIsNonEmpty:Validate[String] = checkIsTrue[String](_.nonEmpty,fv vc "must be non-empty string") valvalidateStringLengthPair:Validate[(String,Int)] = checkIsTrue[(String,Int)]({case (s,l) => s.length() == l}, "string must be of expected length")and run it with the tested value:
validateIsEven(2).isValid
// res7: Boolean = true
validateIsEven(1).isInvalid
// res8: Boolean = true
validateIsNonEmpty("").isInvalid
// res9: Boolean = true
validateIsNonEmpty("abc").isValid
// res10: Boolean = true
validateStringLengthPair(("abc",3)).isValid
// res11: Boolean = true
validateStringLengthPair(("ab",1)).isInvalid
// res12: Boolean = trueValidators can be combined using different strategies:
valvalidateIsPositive:Validate[Int] = checkIsTrue[Int](_ >0, "must be positive integer")
// combine using ANY to validate whether all checks passvalvalidateIsEvenAndPositive:Validate[Int] = all(
validateIsEven, validateIsPositive
)
// or use an infix operator &valevenAndPositive= validateIsEven & validateIsPositive // combine using ANY to validate whether any check passesvalvalidateIsEvenOrPositive:Validate[Int] = any(
validateIsEven, validateIsPositive
)
// or use an infix operator |valevenOrPositive= validateIsEven | validateIsPositive // combine using PRODUCT to validate a pairvalvalidateIsEvenPositivePair:Validate[(Int,Int)] = product(
validateIsEven, validateIsPositive
)
// or use an infix operator *valevenPositivePair= validateIsEven * validateIsPositive evenAndPositive(2).isValid
// res13: Boolean = true
evenAndPositive(1).isInvalid
// res14: Boolean = true
evenAndPositive(-1).isInvalid
// res15: Boolean = true
evenAndPositive(-2).isInvalid
// res16: Boolean = true
evenOrPositive(2).isValid
// res17: Boolean = true
evenOrPositive(1).isValid
// res18: Boolean = true
evenOrPositive(-1).isInvalid
// res19: Boolean = true
evenOrPositive(-2).isValid
// res20: Boolean = true
evenPositivePair((2,1)).isValid
// res21: Boolean = true
evenPositivePair((1,2)).isInvalid
// res22: Boolean = trueValidate objects using checkWith, checkIfSome, checkEach, checkEachIfSome, etc.:
caseclassFoo(a: String, b: Option[Int], c: Boolean, d: Seq[String], e: Bar)
caseclassBar(f: BigDecimal, h: Option[Seq[Int]])
valvalidateBar:Validate[Bar] = all[Bar](
checkIsTrue(_.f.inRange(0,100),".f must be in range 0..100 inclusive"),
checkEachIfSomeWithErrorPrefix(_.h, validateIsEvenAndPositive, i =>s".h[$i] ", isValidIfNone =false)
).withErrorPrefix("[Bar]")
valprefix:AnyRef=>String= o =>s"[${o.getClass.getSimpleName}]"valvalidateFoo:Validate[Foo] = all[Foo](
checkWith(_.a, validateIsNonEmpty),
checkIsTrue(_.a.matches("[A-Z]\\d{3,5}"),".a must follow pattern [A-Z]\\d{3,5}"),
checkIfSome[Foo,Int](_.b, evenOrPositive, isValidIfNone =true).withErrorPrefix(".b"),
conditionally(
_.c,
checkEachWithErrorPrefix(_.d, validateIsNonEmpty & checkIsTrue[String](_.lengthMax(64),"64 characters maximum"), i =>s".d[$i] "),
checkWith[Foo,Bar](_.e, validateBar).withErrorPrefix(".e")
)
).withErrorPrefixComputed(prefix)validateFoo(Foo("X678",Some(2),true,Seq("abc"),Bar(500,Some(Seq(8)))))
// res23: Result = Right(value = ())
validateFoo(Foo("X67",Some(-1),true,Seq("abc",""),Bar(500,Some(Seq(7)))))
// res24: Result = Left(// value = And(// errors = List(// Single(message = "[Foo].a must follow pattern [A-Z]\\d{3,5}"),// Or(// errors = List(// Single(message = "[Foo].bmust be even integer"),// Single(message = "[Foo].bmust be positive integer")// )// ),// Single(message = "[Foo].d[1] must be non-empty string")// )// )// )
validateFoo(Foo("X678",Some(2),false,Seq("abc"),Bar(99,None)))
// res25: Result = Left(// value = Single(message = "[Foo].e[Bar]Expected Some sequence but got None")// )
validateFoo(Foo("X",Some(3),false,Seq("abc",""),Bar(-1,Some(Seq(7,8,9)))))
// res26: Result = Left(// value = And(// errors = List(// Single(message = "[Foo].a must follow pattern [A-Z]\\d{3,5}"),// Single(message = "[Foo].e[Bar].f must be in range 0..100 inclusive"),// Single(message = "[Foo].e[Bar].h[0] must be even integer"),// Single(message = "[Foo].e[Bar].h[2] must be even integer")// )// )// )Tag validator with prefix:
evenOrPositive.apply(-1).errorsSummaryOption
// res27: Option[String] = Some(// value = "must be even integer or must be positive integer"// )
("prefix: "@: evenOrPositive).apply(-1).errorsSummaryOption
// res28: Option[String] = Some(// value = "prefix: must be even integer or prefix: must be positive integer"// )
evenOrPositive.withErrorPrefix("foo_").apply(-1).errorsSummaryOption
// res29: Option[String] = Some(// value = "foo_must be even integer or foo_must be positive integer"// )
evenOrPositive.withErrorPrefixComputed(i =>s"($i) ").apply(-1).errorsSummaryOption
// res30: Option[String] = Some(// value = "(-1) must be even integer or (-1) must be positive integer"// )Debug validator:
// debug input and output
validateFoo.debug.apply(Foo("X678",Some(2),true,Seq("abc"),Bar(500,Some(Seq(8)))))
// Foo(X678,Some(2),true,List(abc),Bar(500,Some(List(8)))) => Valid// res31: Result = Right(value = ())// debug only output
validateFoo.apply(Foo("X678",Some(2),true,Seq("abc"),Bar(500,Some(Seq(8))))).debug
// Valid// res32: Result = Right(value = ())├── .github
│ └── workflows
│ ├── pages.yaml
│ ├── release.yaml
│ └── test.yaml
│
├── .gitignore
├── .scalafmt.conf
├── LICENSE
├── project.scala
├── README.md
├── StringMatchers.test.scala
├── test.sh
├── Validator.scala
└── Validator.test.scala