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

Name Description
Field.fallibleConst a fallible variant of Field.const that allows for supplying values wrapped in an F
Field.fallibleComputed a fallible variant of Field.computed that allows for supplying functions that return values wrapped in an F
Field.fallibleComputedDeep a fallible variant of Field.computedDeep that allows for supplying functions that return values wrapped in an F

import io.github.arainko.ducktape.*

given Mode.Accumulating.Either[String, List]()

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
    {
      def transform(value: Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]]): Person =
        new Person(value._1, value._2._1, value._2._2)
      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]]) => transform(`value₂`)
      )
    }: Either[List[String], Person]
  }: Either[List[String], Person]
}
given Mode.Accumulating.Either[String, List]()

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
    {
      def transform(value: Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]]): Person =
        new Person(value._1, value._2._1, value._2._2)
      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]]) => transform(`value₂`)
      )
    }: Either[List[String], Person]
  }: Either[List[String], Person]
}
given Mode.Accumulating.Either[String, List]()

case class SourceToplevel1(level1: Option[SourceLevel1])
case class SourceLevel1(level2: Option[SourceLevel2])
case class SourceLevel2(int: Int)

case class DestToplevel1(level1: Option[DestLevel1])
case class DestLevel1(level2: Option[DestLevel2])
case class DestLevel2(int: Positive)

val source = SourceToplevel1(Some(SourceLevel1(Some(SourceLevel2(1)))))
source
  .into[DestToplevel1]
  .fallible
  .transform(
    Field.fallibleComputedDeep(
      _.level1.element.level2.element.int, 
      // the type here cannot be inferred automatically and needs to be provided by the user,
      // a nice compiletime error message is shown (with a suggestion on what the proper type to use is) otherwise
      (value: Int) => Positive.makeAccumulating(value + 10L))
    )
// res4: Either[List[String], DestToplevel1] = Right(
//   value = DestToplevel1(
//     level1 = Some(
//       value = DestLevel1(level2 = Some(value = DestLevel2(int = 11L)))
//     )
//   )
// )
{
  val Fallible_this
    : Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List, SourceToplevel1, DestToplevel1] =
    into[SourceToplevel1](source)[DestToplevel1]
      .fallible[[A >: Nothing <: Any] =>> Either[List[String], A], given_Either_String_List.type](given_Either_String_List)
  {
    val source$proxy6: SourceToplevel1 = Fallible_this.inline$source
    val F$proxy6: given_Either_String_List.type = Fallible_this.inline$F
    {
      def transform(value: None | Some[DestLevel1]): DestToplevel1 = new DestToplevel1(value)
      F$proxy6.map[None | Some[DestLevel1], DestToplevel1](
        source$proxy6.level1 match {
          case None =>
            F$proxy6.pure[None.type](None)
          case Some(value) =>
            F$proxy6.map[DestLevel1, Some[DestLevel1]](
              {
                def `transform₂`(`value₂`: None | Some[DestLevel2]): DestLevel1 = new DestLevel1(`value₂`)
                F$proxy6.map[None | Some[DestLevel2], DestLevel1](
                  `value₃`.level2 match {
                    case None =>
                      F$proxy6.pure[None.type](None)
                    case Some(value) =>
                      F$proxy6.map[DestLevel2, Some[DestLevel2]](
                        {
                          def `transform₃`(`value₄`: Positive): DestLevel2 = new DestLevel2(`value₄`)
                          F$proxy6.map[Positive, DestLevel2](
                            {
                              val `value₅`: Int = `value₆`.int
                              MdocApp.this.newtypes.Positive.makeAccumulating(`value₅`.+(10L))
                            },
                            (`value₇`: Positive) => `transform₃`(`value₇`)
                          )
                        },
                        (`value₈`: DestLevel2) => Some.apply[DestLevel2](`value₈`)
                      )
                  },
                  (`value₉`: None | Some[DestLevel2]) => `transform₂`(`value₉`)
                )
              },
              (`value₁₀`: DestLevel1) => Some.apply[DestLevel1](`value₁₀`)
            )
        },
        (`value₁₁`: None | Some[DestLevel1]) => transform(`value₁₁`)
      )
    }: Either[List[String], DestToplevel1]
  }: Either[List[String], DestToplevel1]
}

Coproduct configurations

Name Description
Case.fallibleConst a fallible variant of Case.const that allows for supplying values wrapped in an F
Case.fallibleComputed a fallible variant of Case.computed that allows for supplying functions that return values wrapped in an F

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]()

wire.ReleaseKind.Single
  .into[domain.ReleaseKind]
  .fallible
  .transform(
    Case.fallibleConst(_.at[wire.ReleaseKind.Single.type], Left("Unsupported release kind"))
  )
// res6: 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$proxy8: ReleaseKind = Fallible_this.inline$source
    val F$proxy8: given_Either_String.type = Fallible_this.inline$F
    if source$proxy8.isInstanceOf[LP.type] then F$proxy8.pure[ReleaseKind](domain.ReleaseKind.LP)
    else if source$proxy8.isInstanceOf[EP.type] then F$proxy8.pure[ReleaseKind](domain.ReleaseKind.EP)
    else if source$proxy8.isInstanceOf[Single.type] then 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]()

// 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)
  )
// res8: 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$proxy10: ReleaseKind = Fallible_this.inline$source
    val F$proxy10: given_Either_String.type = Fallible_this.inline$F
    if source$proxy10.isInstanceOf[LP.type] then F$proxy10.pure[ReleaseKind](domain.ReleaseKind.LP)
    else if source$proxy10.isInstanceOf[EP.type] then F$proxy10.pure[ReleaseKind](domain.ReleaseKind.EP)
    else if source$proxy10.isInstanceOf[Single.type] then {
      val value: ReleaseKind = source$proxy10.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]()

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$proxy12: given_Either_String_List.type = Fallible_this.inline$F
    {
      def transform(value: Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]]): Person =
        new Person(value._1, value._2._1, value._2._2)
      F$proxy12.map[Tuple2[NonEmptyString, Tuple2[Positive, NonEmptyString]], Person](
        F$proxy12.product[NonEmptyString, Tuple2[Positive, NonEmptyString]](
          MdocApp.this.newtypes.NonEmptyString.makeAccumulating("IAmAlwaysValidNow!"),
          F$proxy12.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]]) => transform(`value₂`)
      )
    }: 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]()

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$proxy14: given_Either_String_List.type = Fallible_this.inline$F
    {
      def transform(`value₂`: NonEmptyString): Person = function$proxy6.apply(`value₂`, value.age, value.socialSecurityNo)
      F$proxy14.map[NonEmptyString, Person](
        MdocApp.this.newtypes.NonEmptyString.makeAccumulating("IAmAlwaysValidNow!"),
        (`value₃`: NonEmptyString) => transform(`value₃`)
      )
    }: Either[List[String], Person]
  }): Fallible[[A >: Nothing <: Any] =>> Either[List[String], A], Person, Person]
}