Configuring fallible transformations

Prelude

If we were to dissect how the types behind config options are structured, we'd see this:

opaque type Field[A, B] <: Field.Fallible[Nothing, A, B] = Field.Fallible[Nothing, A, B]

object Field {
  opaque type Fallible[+F[+x], A, B] = Unit
}

Non-fallible config options are a subtype of fallible configs, which means that all the things mentioned in configuring transformations are also applicable to fallible configurations (and should be read before diving into this doc).

Having said all that, let's declare a wire/domain model pair we'll be working on:

object wire:
  case class Person(name: String, age: Long, socialSecurityNo: String)
import newtypes.*

object domain:
  case class Person(name: NonEmptyString, age: Positive, socialSecurityNo: NonEmptyString)
import io.github.arainko.ducktape.*

object newtypes:
  opaque type NonEmptyString <: String = String

  object NonEmptyString:
    def make(value: String): Either[String, NonEmptyString] =
      Either.cond(!value.isBlank, value, s"not a non-empty string")

    def makeAccumulating(value: String): Either[List[String], NonEmptyString] =
      make(value).left.map(_ :: Nil)

    given failFast: Transformer.Fallible[[a] =>> Either[String, a], String, NonEmptyString] =
      make

    given accumulating: Transformer.Fallible[[a] =>> Either[List[String], a], String, NonEmptyString] =
      makeAccumulating

  opaque type Positive <: Long = Long

  object Positive:
    def make(value: Long): Either[String, Positive] =
      Either.cond(value > 0, value, "not a positive long")

    def makeAccumulating(value: Long): Either[List[String], Positive] =
      make(value).left.map(_ :: Nil)

    given failFast: Transformer.Fallible[[a] =>> Either[String, a], Long, Positive] =
      make

    given accumulating: Transformer.Fallible[[a] =>> Either[List[String], a], Long, Positive] =
      makeAccumulating

...and some input examples:

// this should trip up our validation
val bad = wire.Person(name = "", age = -1, socialSecurityNo = "SOCIALNO")

// this one should pass
val good = wire.Person(name = "ValidName", age = 24, socialSecurityNo = "SOCIALNO")

Product configurations

import io.github.arainko.ducktape.*

given Mode.Accumulating.Either[String, List] with {}

bad
  .into[domain.Person]
  .fallible
  .transform(
    Field.fallibleConst(_.name, NonEmptyString.makeAccumulating("ConstValidName")),
    Field.fallibleConst(_.age, Positive.makeAccumulating(25))
  )
// res0: Either[List[String], Person] = Right(
//   value = Person(
//     name = "ConstValidName",
//     age = 25L,
//     socialSecurityNo = "SOCIALNO"
//   )
// )
{
  val Fallible_this: Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List, Person, Person] =
    into[Person](MdocApp.this.bad)[MdocApp.this.domain.Person]
      .fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List.type](given_Either_String_List)
  {
    val source$proxy2: Person = Fallible_this.inline$source
    val F$proxy2: given_Either_String_List.type = Fallible_this.inline$F
    F$proxy2.map[Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]], Person](
      F$proxy2.product[NonEmptyString, Tuple2[Positive, NonEmptyString]](
        MdocApp.this.newtypes.NonEmptyString.makeAccumulating("ConstValidName"),
        F$proxy2.product[Positive, NonEmptyString](
          MdocApp.this.newtypes.Positive.makeAccumulating(25L),
          MdocApp.this.newtypes.NonEmptyString.accumulating.transform(source$proxy2.socialSecurityNo)
        )
      ),
      (value: Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]]) =>
        new Person(name = value._1, age = value._2._1, socialSecurityNo = value._2._2)
    ): Either[List[String], Person]
  }: Either[List[String], Person]
}
given Mode.Accumulating.Either[String, List] with {}

bad
  .into[domain.Person]
  .fallible
  .transform(
    Field.fallibleComputed(_.name, uvp => NonEmptyString.makeAccumulating(uvp.name + "ConstValidName")),
    Field.fallibleComputed(_.age, uvp => Positive.makeAccumulating(uvp.age + 25))
  )
// res2: Either[List[String], Person] = Right(
//   value = Person(
//     name = "ConstValidName",
//     age = 24L,
//     socialSecurityNo = "SOCIALNO"
//   )
// )
{
  val Fallible_this: Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List, Person, Person] =
    into[Person](MdocApp.this.bad)[MdocApp.this.domain.Person]
      .fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List.type](given_Either_String_List)
  {
    val source$proxy4: Person = Fallible_this.inline$source
    val F$proxy4: given_Either_String_List.type = Fallible_this.inline$F
    F$proxy4.map[Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]], Person](
      F$proxy4.product[NonEmptyString, Tuple2[Positive, NonEmptyString]](
        MdocApp.this.newtypes.NonEmptyString.makeAccumulating(source$proxy4.name.+("ConstValidName")),
        F$proxy4.product[Positive, NonEmptyString](
          MdocApp.this.newtypes.Positive.makeAccumulating(source$proxy4.age.+(25)),
          MdocApp.this.newtypes.NonEmptyString.accumulating.transform(source$proxy4.socialSecurityNo)
        )
      ),
      (value: Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]]) =>
        new Person(name = value._1, age = value._2._1, socialSecurityNo = value._2._2)
    ): Either[List[String], Person]
  }: Either[List[String], Person]
}

Coproduct configurations

Let's define a wire enum (pretend that it's coming from... somewhere) and a domain enum that doesn't exactly align with the wire one.

object wire:
  enum ReleaseKind:
    case LP, EP, Single

object domain:
  enum ReleaseKind:
    case EP, LP
given Mode.FailFast.Either[String] with {}

wire.ReleaseKind.Single
  .into[domain.ReleaseKind]
  .fallible
  .transform(
    Case.fallibleConst(_.at[wire.ReleaseKind.Single.type], Left("Unsupported release kind"))
  )
// res4: Either[String, ReleaseKind] = Left(value = "Unsupported release kind")
{
  val Fallible_this: Fallible[[A >: Nothing <: Any] =>> Either[String, A], given_Either_String, ReleaseKind, ReleaseKind] =
    into[ReleaseKind](wire.ReleaseKind.Single)[domain.ReleaseKind]
      .fallible[[A >: Nothing <: Any] =>> Either[String, A], given_Either_String.type](given_Either_String)
  {
    val source$proxy6: ReleaseKind = Fallible_this.inline$source
    val F$proxy6: given_Either_String.type = Fallible_this.inline$F
    if (source$proxy6.isInstanceOf[LP.type]) F$proxy6.pure[LP.type](domain.ReleaseKind.LP)
    else if (source$proxy6.isInstanceOf[EP.type]) F$proxy6.pure[EP.type](domain.ReleaseKind.EP)
    else if (source$proxy6.isInstanceOf[Single.type]) Left.apply[String, Nothing]("Unsupported release kind")
    else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape."): Either[String, ReleaseKind]
  }: Either[String, ReleaseKind]
}
given Mode.FailFast.Either[String] with {}

// Type inference is tricky with this one. The function being passed in needs to be typed with the exact expected type.
def handleSingle(value: wire.ReleaseKind): Either[String, domain.ReleaseKind] =
  Left("It's a single alright, too bad we don't support it")

wire.ReleaseKind.Single
  .into[domain.ReleaseKind]
  .fallible
  .transform(
    Case.fallibleComputed(_.at[wire.ReleaseKind.Single.type], handleSingle)
  )
// res6: Either[String, ReleaseKind] = Left(
//   value = "It's a single alright, too bad we don't support it"
// )
{
  val Fallible_this: Fallible[[A >: Nothing <: Any] =>> Either[String, A], given_Either_String, ReleaseKind, ReleaseKind] =
    into[ReleaseKind](wire.ReleaseKind.Single)[domain.ReleaseKind]
      .fallible[[A >: Nothing <: Any] =>> Either[String, A], given_Either_String.type](given_Either_String)
  {
    val source$proxy8: ReleaseKind = Fallible_this.inline$source
    val F$proxy8: given_Either_String.type = Fallible_this.inline$F
    if (source$proxy8.isInstanceOf[LP.type]) F$proxy8.pure[LP.type](domain.ReleaseKind.LP)
    else if (source$proxy8.isInstanceOf[EP.type]) F$proxy8.pure[EP.type](domain.ReleaseKind.EP)
    else if (source$proxy8.isInstanceOf[Single.type]) {
      val value: ReleaseKind = source$proxy8.asInstanceOf[Single.type]
      handleSingle(value)
    } else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape."): Either[String, ReleaseKind]
  }: Either[String, ReleaseKind]
}

Building custom instances of fallible transformers

Life is not always lolipops and crisps and sometimes you need to write a typeclass instance by hand. Worry not though, just like in the case of total transformers, we can easily define custom instances with the help of the configuration DSL (which, let's write it down once again, is a superset of total transformers' DSL).

By all means go wild with the configuration options, I'm too lazy to write them all out here again.

given Mode.Accumulating.Either[String, List] with {}

val customAccumulating =
  Transformer
    .define[wire.Person, domain.Person]
    .fallible
    .build(
      Field.fallibleConst(_.name, NonEmptyString.makeAccumulating("IAmAlwaysValidNow!"))
    )
{
  val Fallible_this: Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List, Person, Person] =
    Transformer
      .define[wire.Person, domain.Person]
      .fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List.type](given_Either_String_List)
  new FromFunction[[A >: Nothing <: Any] =>> Either[List[String], A], Person, Person]((source: Person) => {
    val F$proxy10: given_Either_String_List.type = Fallible_this.inline$F
    F$proxy10.map[Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]], Person](
      F$proxy10.product[NonEmptyString, Tuple2[Positive, NonEmptyString]](
        MdocApp.this.newtypes.NonEmptyString.makeAccumulating("IAmAlwaysValidNow!"),
        F$proxy10.product[Positive, NonEmptyString](
          MdocApp.this.newtypes.Positive.accumulating.transform(source.age),
          MdocApp.this.newtypes.NonEmptyString.accumulating.transform(source.socialSecurityNo)
        )
      ),
      (value: Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]]) =>
        new Person(name = value._1, age = value._2._1, socialSecurityNo = value._2._2)
    ): Either[List[String], Person]
  }): Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], Person, Person]
}

And for the ones that are not keen on writing out method arguments:

given Mode.Accumulating.Either[String, List] with {}

val customAccumulatingVia =
  Transformer
    .defineVia[wire.Person](domain.Person.apply)
    .fallible
    .build(
      Field.fallibleConst(_.name, NonEmptyString.makeAccumulating("IAmAlwaysValidNow!"))
    )
{
  val $proxy2: DefinitionViaBuilder {
    type PartiallyApplied >: [Source >: Nothing <: Any] =>> Unit <: [Source >: Nothing <: Any] =>> Unit
  } = DefinitionViaBuilder.$asInstanceOf$[
    DefinitionViaBuilder {
      type PartiallyApplied >: [Source >: Nothing <: Any] =>> Unit <: [Source >: Nothing <: Any] =>> Unit
    }
  ]
  val DefinitionViaBuilder$_this: $proxy2.type = $proxy2
  val partial$proxy2: PartiallyApplied[Person] & PartiallyApplied[Person] =
    Transformer.defineVia[wire.Person].$asInstanceOf$[PartiallyApplied[Person] & PartiallyApplied[Person]]
  val builder: DefinitionViaBuilder[Person, Nothing, Function3[NonEmptyString, Positive, NonEmptyString, Person], Nothing] =
    DefinitionViaBuilder$_this.inline$instance[Person, Function3[NonEmptyString, Positive, NonEmptyString, Person]](
      (name: NonEmptyString, age: Positive, socialSecurityNo: NonEmptyString) =>
        domain.Person.apply(name, age, socialSecurityNo)
    )
  val $proxy5: DefinitionViaBuilder {
    type PartiallyApplied >: [Source >: Nothing <: Any] =>> Unit <: [Source >: Nothing <: Any] =>> Unit
  } = DefinitionViaBuilder.$asInstanceOf$[
    DefinitionViaBuilder {
      type PartiallyApplied >: [Source >: Nothing <: Any] =>> Unit <: [Source >: Nothing <: Any] =>> Unit
    }
  ]
  val $proxy6: newtypes {
    type Positive >: Long <: Long
    type NonEmptyString >: String <: String
  } = MdocApp.this.newtypes.$asInstanceOf$[
    newtypes {
      type Positive >: Long <: Long
      type NonEmptyString >: String <: String
    }
  ]
  val Fallible_this: Fallible[
    [A >: Nothing <: Any] =>> Either[List[String], A],
    given_Either_String_List,
    Person,
    Person,
    Function3[NonEmptyString, Positive, NonEmptyString, Person],
    FunctionArguments {
      val name: NonEmptyString
      val age: Positive
      val socialSecurityNo: NonEmptyString
    }
  ] = builder
    .asInstanceOf[[args >: Nothing <: FunctionArguments, retTpe >: Nothing <: Any] =>> DefinitionViaBuilder[
      Person,
      retTpe,
      Function3[NonEmptyString, Positive, NonEmptyString, Person],
      args
    ][
      FunctionArguments {
        val name: NonEmptyString
        val age: Positive
        val socialSecurityNo: NonEmptyString
      },
      Person
    ]]
    .fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List.type](given_Either_String_List)
  new FromFunction[[A >: Nothing <: Any] =>> Either[List[String], A], Person, Person]((value: Person) => {
    val function$proxy6: Function3[NonEmptyString, Positive, NonEmptyString, Person] = Fallible_this.inline$function
    val F$proxy12: given_Either_String_List.type = Fallible_this.inline$F
    F$proxy12.map[NonEmptyString, Person](
      MdocApp.this.newtypes.NonEmptyString.makeAccumulating("IAmAlwaysValidNow!"),
      (`value₂`: NonEmptyString) => function$proxy6.apply(`value₂`, value.age, value.socialSecurityNo)
    ): Either[List[String], Person]
  }): Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], Person, Person]
}