Skip to content

demo the usage of newtypes and refined librairies in ddd, french documentation

Notifications You must be signed in to change notification settings

hajlaoui-nader/scala-domain-validation

Repository files navigation

Refined newtypes

types are a lie

on crée un titre, easy right ?

case class TicketS(pnr: String, tcn: String, fareCode: String)

val ticketS = TicketS(pnr = "UEMZTN", tcn = "459036690", fareCode = "AE98")

on peut écrire aussi 😰

val emptyTicket = TicketS(pnr = "", tcn = "", fareCode = "")

donc on peut dire

types are a lie

value types à la rescousse

case class PnrV(value: String) extends AnyVal
case class TcnV(value: String) extends AnyVal
case class FareCodeV(value: String) extends AnyVal

case class TicketV(pnr: PnrV, tcn: TcnV, fareCode: FareCodeV)

val ticketV = TicketV(pnr = PnrV("UEMZTN"), tcn = TcnV("459036690"), fareCode = FareCodeV("AE98"))

mais on peut aussi écrire 🤔

val emptyTicketV = TicketV(pnr = PnrV(""), tcn = TcnV(""), fareCode = FareCodeV(""))

smart constructor ftw ?

import cats.implicits._

case class PnrSmart private(value: String) extends AnyVal

object PnrSmart {
    def mkPnr(value: String): Option[PnrSmart] = 
        if(value.nonEmpty) Some(PnrSmart(value)) else None
}

case class TcnSmart private(value: String) extends AnyVal

object TcnSmart {
    def mkTcn(value: String): Option[TcnSmart] = 
        if(value.nonEmpty) Some(TcnSmart(value)) else None
}

case class FareCodeSmart private(value: String) extends AnyVal

object FareCodeSmart {
    def mkFareCode(value: String): Option[FareCodeSmart] = 
        if(value.nonEmpty) Some(FareCodeSmart(value)) else None
}

case class TicketSmart(pnr: PnrSmart, tcn: TcnSmart, fareCode: FareCodeSmart)

(
    PnrSmart.mkPnr("UEMZTN"),
    TcnSmart.mkTcn("459036690"),
    FareCodeSmart.mkFareCode("AE98") 
).mapN {
    case (pnr, tcn, fareCode) => 
        TicketSmart(pnr = pnr, tcn = tcn, fareCode = fareCode)
}

beaucoup de code, but still on peut etre crado 😕

(
    PnrSmart.mkPnr("UEMZTN"),
    TcnSmart.mkTcn("459036690"),
    FareCodeSmart.mkFareCode("AE98") 
).mapN {
    case (pnr, tcn, fareCode) => 
        TicketSmart(pnr = pnr.copy(value = ""), tcn = tcn, fareCode = fareCode)
}

On peut toujours essayer de bloquer cette méthode copy qui vient par défaut avec les case class en utilisant les sealed abstract case class 😉 4 mots clés qui dit plus

sealed abstract class PnrStrong (val value: String) 

object PnrStrong {
    def mkPnrStrong(value: String): Option[PnrStrong] = 
        (value.nonEmpty).guard[Option].as(new PnrStrong(value){}) 
}

😱😱 quelle horreur ce code, beaucoup de boilerplate et on perd l'optimisation de l'allocation mémoire ! wouhou 😭 !

new era, newtypes

En utilisant les value classes, scala ne nous garantit pas à 100% l'optimisation de l'allocation mémoire, et il existe des cas ou scala explicitement ne fait pas cette optimisation:

  • lorsque la value class est assigné à un array
  • quand on fait des runtime type tests, comme le fameux pattern matching

pour garder un code facile à lire, optimisé, ma recommendation est d'utiliser la librairie Newtype

Newtype garantit zero cost wrappers WITH NO RUNTIME OVERHEAD et enlève la méthode copy 🎉

import io.estatico.newtype.macros._

@newtype case class TcnN(value: String)

@newtype  case class PnrN(value: String)

@newtype case class FareCodeN(value: String)

tout a l'air bon, sauf qu'on peut toujours écrire

val tcnN = TcnN("")

📝 Newtypes résout uniquement le problème de zero-cost wrappers, mais ne résout pas le problème de la validation

let's refine all this

La librairie refined nous permet de valider les données au compile time et au runtime time.

import eu.timepit.refined.types.string._
import eu.timepit.refined.auto._

@newtype case class TcnRefined(value: NonEmptyString)

val tcnRefined = TcnRefined("123456")

// val emptyTcnRefined = Tcn("") // ceci ne compile pas 🎉

refined semble etre une bonne piste, elle s'intègre parfaitement avec Circe, Scalacheck, PureConfig et beaucoup d'autres librairies de l'ecosystème scala.

runtime validation

le premier exemple a montré la capacité de faire de la validation au compile time et la combinaison avec les newtypes. Dans cette partie nous allons explorer la validation au runtime, des données qu'on recoit par exemple d'une requete HTTP ou autre service ou la compile validation n'est pas possible.

Refined propose une fonction générique, qui est définit comme suit:

def refineV[P]: RefinePartiallyApplied[P] = new RefinePartiallyApplied[P]

// Note le P == Predicate

final class RefinePartiallyApplied[P] {
    def apply[T](t: T)(implicit v: Validate[T, P]): Either[String, Refined[T, P]]
}

dans le prochain exemple, supposons que str est une runtime value.

import eu.timepit.refined.api.Refined
import eu.timepit.refined._ 
import eu.timepit.refined.auto._
import eu.timepit.refined.api.RefinedTypeOps
import eu.timepit.refined.numeric.Greater

val str: String = "some runtime value"

val res: Either[String, NonEmptyString] = NonEmptyString.from(str)

val emptyStr: String = ""

val resEmpty: Either[String, NonEmptyString] = NonEmptyString.from(emptyStr)

// on peut faire aussi 
type GTFive = Int Refined Greater[5] // je définis un nouveau type

object GTFive extends RefinedTypeOps[GTFive, Int] // je définis l'objet et je l'enrichis avec les fonctions de RefinedType

val number: Int = 33

val gtFiveRes: Either[String, GTFive] = GTFive.from(number) // je bénificie de la fonction `from`

on remarque que la validation au runtime se fait en utilisant des Either, qui est une Monad, ec qui veut dire que la validation se fait séquentiellement, ca échoue à la première erreur. L'utilisation de cats.data.Validated qui ressemble beaucoup à Either sinon le fait que c'est une Applicative capable de cumuler les erreurs et valider la data en parallèle. Le type qu'on utilise assez souvent ValidatedNel[E,A] qui est un alias de Validated[NonEmptyString[E], A]

import cats.implicits._
import cats.data.ValidatedNel

case class MyType(a: NonEmptyString, b: GTFive)

def validate(a: String, b: Int): ValidatedNel[String, MyType] =
    (   
        NonEmptyString.from(a).toValidatedNel,
        GTFive.from(b).toValidatedNel
    ).mapN(MyType.apply)


val expectValidationError = validate("", 3)    

revenons à notre exemple de Ticket

type PnrR = NonEmptyString
object PnrR extends RefinedTypeOps[PnrR, String]

type TcnR = NonEmptyString
object TcnR extends RefinedTypeOps[TcnR, String]

type FareCodeR = NonEmptyString
object FareCodeR extends RefinedTypeOps[FareCodeR, String]

@newtype case class Tcn(value: TcnR)
@newtype case class Pnr(value: PnrR)
@newtype case class FareCode(value: FareCodeR)

case class Ticket(pnr: Pnr, tcn: Tcn, fareCode: FareCode)

def mkTicket(t: String, p: String, fc: String): ValidatedNel[String, Ticket] = 
    (
        PnrR.from(p).toValidatedNel.map(Pnr.apply),
        TcnR.from(t).toValidatedNel.map(Tcn.apply),
        FareCodeR.from(fc).toValidatedNel.map(FareCode.apply)
    ).mapN(Ticket.apply)

ouf 😅 ca fait le job mais beaucoup de boilerplate, mais on remarque l'émergence d'un pattern. Idéalement on voudrait arriver à écrire

def mkTicket(t: String, p: String, fc: String): ValidatedNel[String, Ticket] = 
    (
        p.as[Pnr].validate,
        t.as[Tcn].validate,
        fc.as[FareCode].validate
    ).mapN(Ticket.apply)  

let's do it

object NewtypeRefinedOps {
    import io.estatico.newtype.Coercible
    import io.estatico.newtype.ops._
    import eu.timepit.refined._
    import eu.timepit.refined.api._
    import eu.timepit.refined.auto._

    
    final class NewtypeRefinedPartiallyApplied[A] {
        def apply[T, P](raw: T)(implicit c: Coercible[Refined[T, P], A], v: Validate[T, P]): ValidatedNel[String, A] =
        refineV[P](raw).toValidatedNel.map(_.coerce[A])
    }
    
    def validate[A]: NewtypeRefinedPartiallyApplied[A] =
        new NewtypeRefinedPartiallyApplied[A]

    implicit class NewtypeOps[T](raw: T) {
    def as[A]: NewtypePartiallyApplied[A, T] = new NewtypePartiallyApplied[A, T](raw)
  }

  final class NewtypePartiallyApplied[A, T](raw: T) {
    def validate[P](implicit
        c: Coercible[Refined[T, P], A],
        v: Validate[T, P]
    ): ValidatedNel[String, A] =
      refineV[P](raw).toValidatedNel.map(_.coerce[A])
  }

}

and now ... on peut écrire avec le sourire

import NewtypeRefinedOps._

def mkTicket2(t: String, p: String, fc: String): ValidatedNel[String, Ticket] = 
    (
        p.as[Pnr].validate,
        t.as[Tcn].validate,
        fc.as[FareCode].validate
    ).mapN(Ticket.apply)  


val enfinErrorTicket = mkTicket2("pnr", "", "AE93")

val enfinGoodTicket = mkTicket2("UEMZTN", "459036690", "AE98")

donc en fin de compte, les types ARE ALIVE, ALIIIIVE..
types are real

Intégration avec circe

on peut facilement intégrer les librairies newtypes et refined avec circe et utiliser le typeclass derivation afin de génèrer les decoders/encoders. on peut utiliser la librairie derevo pour le typeclass derivation

exemple

About

demo the usage of newtypes and refined librairies in ddd, french documentation

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages