Making the most out of fallible transformations
Now for the meat and potatoes of fallible transformations. To make use of the derivation mechanism that ducktape
provides we should strive for our model to be modeled in a specific way - with a new nominal type per each validated field, which comes down to... Newtypes!
Let's define a minimalist newtype abstraction that will also do validation (this is a one-time effort that can easily be extracted to a library):
import io.github.arainko.ducktape.*
abstract class NewtypeValidated[A](pred: A => Boolean, errorMessage: String) {
opaque type Type = A
protected def unsafe(value: A): Type = value
def make(value: A): Either[String, Type] = Either.cond(pred(value), value, errorMessage)
def makeAccumulating(value: A): Either[List[String], Type] =
make(value).left.map(_ :: Nil)
extension (self: Type) {
def value: A = self
}
// these instances will be available in the implicit scope of `Type` (that is, our newtype)
given accumulatingWrappingTransformer: Transformer.Fallible[[a] =>> Either[List[String], a], A, Type] = makeAccumulating(_)
given failFastWrappingTransformer: Transformer.Fallible[[a] =>> Either[String, a], A, Type] = make(_)
given unwrappingTransformer: Transformer[Type, A] = _.value
}
Now let's get back to the definition of Person
and tweak it a little:
case class Person(name: Name, age: Age, socialSecurityNo: SSN)
object Name extends NewtypeValidated[String](str => !str.isBlank, "Name should not be blank!")
type Name = Name.Type
object Age extends NewtypeValidated[Int](int => int > 0, "Age should be positive!")
type Age = Age.Type
object SSN extends NewtypeValidated[String](str => str.length > 5, "SSN should be longer than 5!")
type SSN = SSN.Type
We introduce a newtype for each field, this way we can keep our invariants at compiletime and also let ducktape
do its thing.
case class UnvalidatedPerson(name: String, age: Int, socialSecurityNo: String)
// this should trip up our validation
val bad = UnvalidatedPerson(name = "", age = -1, socialSecurityNo = "SOCIALNO")
// this one should pass
val good = UnvalidatedPerson(name = "ValidName", age = 24, socialSecurityNo = "SOCIALNO")
Fallible transformations wrapped in some type F
are derived automatically for case classes given that a Transformer.Fallible
instance exists for F
and all of the fields of the source type have a corresponding counterpart in the destination type and each one of them has an instance of either Transformer.Fallible
or a total Transformer
in scope.
given Mode.Accumulating.Either[String, List]()
bad.fallibleTo[Person]
// res0: Either[List[String], Person] = Left(
// value = List("Name should not be blank!", "Age should be positive!")
// )
good.fallibleTo[Person]
// res1: Either[List[String], Person] = Right(
// value = Person(name = "ValidName", age = 24, socialSecurityNo = "SOCIALNO")
// )
(({
def transform(value: Tuple2[Type, Tuple2[Type, Type]]): Person = new Person(value._1, value._2._1, value._2._2)
given_Either_String_List.map[Tuple2[Type, Tuple2[Type, Type]], Person](
given_Either_String_List.product[Type, Tuple2[Type, Type]](
Name.accumulatingWrappingTransformer.transform(bad.name),
given_Either_String_List.product[Type, Type](
Age.accumulatingWrappingTransformer.transform(bad.age),
SSN.accumulatingWrappingTransformer.transform(bad.socialSecurityNo)
)
),
(`value₂`: Tuple2[Type, Tuple2[Type, Type]]) => transform(`value₂`)
)
}: Either[List[String], Person]): Either[List[String], Person])
Same goes for instances that do fail fast transformations (you need Mode.FailFast[F]
in scope in this case)
given Mode.FailFast.Either[String]()
bad.fallibleTo[Person]
// res3: Either[String, Person] = Left(value = "Name should not be blank!")
good.fallibleTo[Person]
// res4: Either[String, Person] = Right(
// value = Person(name = "ValidName", age = 24, socialSecurityNo = "SOCIALNO")
// )
((given_Either_String.flatMap[Type, Person](
Name.failFastWrappingTransformer.transform(bad.name),
(field0: Type) =>
given_Either_String.flatMap[Type, Person](
Age.failFastWrappingTransformer.transform(bad.age),
(field1: Type) =>
given_Either_String.map[Type, Person](
SSN.failFastWrappingTransformer.transform(bad.socialSecurityNo),
(field2: Type) => new Person(field0, field1, field2)
)
)
): Either[String, Person]): Either[String, Person])