Transfomation rules

Let's go over the priority and rules that ducktape uses to create a transformation (in the same order they're tried in the implementation):

1. User supplied Transformers

Custom instances of a Transfomer are always prioritized since these also function as an extension mechanism of the library.

import io.github.arainko.ducktape.*

given Transformer[String, List[String]] = str => str :: Nil

"single value".to[List[String]]
// res0: List[String] = List("single value")
((MdocApp.this.given_Transformer_String_List.transform("single value"): List[String]): List[String])

2. Upcasting

Transforming a type to its supertype is just an upcast.

// (Int | String) >: Int
1.to[Int | String]
// res2: Int | String = 1
((1: Int | String): Int | String)

3. Mapping over an Option

Transforming between options comes down to mapping over it and recursively deriving a transformation for the value inside.

given Transformer[Int, String] = int => int.toString

Option(1).to[Option[String]]
// res4: Option[String] = Some(value = "1")
{
  val source$proxy2: Option[Int] = Option.apply[Int](1)
  (source$proxy2
    .map[String]((src: Int) => MdocApp.this.given_Transformer_Int_String.transform(src)): Option[String]): Option[String]
}

4. Transforming and wrapping in an Option

If a transformation between two types is possible then transforming between the source type and an Option of the destination type is just wrapping the transformation result in a Some.

1.to[Option[Int | String]]
// res6: Option[Int | String] = Some(value = 1)
((Some.apply[Int | String](1): Option[Int | String]): Option[Int | String])

5. Mapping over and changing the collection type

//`.to` is already a method on collections
import io.github.arainko.ducktape.to as convertTo

List(1, 2, 3, 4).convertTo[Vector[Int | String]]
// res8: Vector[Int | String] = Vector(1, 2, 3, 4)
{
  val source$proxy4: List[Int] = List.apply[Int](1, 2, 3, 4)
  (source$proxy4
    .map[Int | String]((src: Int) => src)
    .to[Vector[Int | String]](iterableFactory[Int | String]): Vector[Int | String]): Vector[Int | String]
}

6. Transforming between case classes

A source case class can be transformed into the destination case class given that:

import io.github.arainko.ducktape.*

case class SourceToplevel(level1: SourceLevel1)
case class SourceLevel1(extra: String, int: Int, level2s: List[SourceLevel2])
case class SourceLevel2(value: Int)

case class DestToplevel(level1: DestLevel1)
case class DestLevel1(int: Int | String, level2s: Vector[DestLevel2])
case class DestLevel2(value: Option[Int])

SourceToplevel(SourceLevel1("extra", 1, List(SourceLevel2(1), SourceLevel2(2)))).to[DestToplevel]
// res11: DestToplevel = DestToplevel(
//   level1 = DestLevel1(
//     int = 1,
//     level2s = Vector(
//       DestLevel2(value = Some(value = 1)),
//       DestLevel2(value = Some(value = 2))
//     )
//   )
// )
{
  val source$proxy6: SourceToplevel =
    SourceToplevel.apply(SourceLevel1.apply("extra", 1, List.apply[SourceLevel2](SourceLevel2.apply(1), SourceLevel2.apply(2))))
  (new DestToplevel(level1 =
    new DestLevel1(
      int = source$proxy6.level1.int,
      level2s = source$proxy6.level1.level2s
        .map[DestLevel2]((src: SourceLevel2) => new DestLevel2(value = Some.apply[Int](src.value)))
        .to[Vector[DestLevel2]](iterableFactory[DestLevel2])
    )
  ): DestToplevel): DestToplevel
}

7. Transforming between enums/sealed traits

A source coproduct can be transformed into the destination coproduct given that:

sealed trait PaymentMethod

object PaymentMethod {
  case class Card(name: String, digits: Long, expires: Long) extends PaymentMethod
  case object Cash extends PaymentMethod
  case class PayPal(email: String) extends PaymentMethod
}

enum OtherPaymentMethod {
  case Card(name: String, digits: Long, expires: Long)
  case PayPal(email: String)
  case Cash
  case FakeMoney
}

(PaymentMethod.Cash: PaymentMethod).to[OtherPaymentMethod]
// res13: OtherPaymentMethod = Cash
{
  val source$proxy8: PaymentMethod = PaymentMethod.Cash: PaymentMethod
  (if (source$proxy8.isInstanceOf[Card])
     new Card(
       name = source$proxy8.asInstanceOf[Card].name,
       digits = source$proxy8.asInstanceOf[Card].digits,
       expires = source$proxy8.asInstanceOf[Card].expires
     )
   else if (source$proxy8.isInstanceOf[Cash.type]) Cash
   else if (source$proxy8.isInstanceOf[PayPal]) new PayPal(email = source$proxy8.asInstanceOf[PayPal].email)
   else
     throw new RuntimeException(
       "Unhandled case. This is most likely a bug in ducktape."
     ): OtherPaymentMethod): OtherPaymentMethod
}

8. Same named singletons

Transformations between same named singletons come down to just reffering to the destination singleton.

object example1 {
  case object Singleton
}

object example2 {
  case object Singleton
}

example1.Singleton.to[example2.Singleton.type]
// res15: Singleton = Singleton
Singleton

9. Unwrapping a value class

case class Wrapper1(value: Int) extends AnyVal

Wrapper1(1).to[Int]
// res17: Int = 1
{
  val source$proxy10: Wrapper1 = Wrapper1.apply(1)
  (source$proxy10.value: Int): Int
}

10. Wrapping a value class

case class Wrapper2(value: Int) extends AnyVal

1.to[Wrapper2]
// res19: Wrapper2 = Wrapper2(value = 1)
((new Wrapper2(1): Wrapper2): Wrapper2)

11. Automatically derived Transformer.Derived

Instances of Transformer.Derived are automatically derived as a fallback to support use cases where a generic type (eg. a field of a case class) is unknown at definition site.

Note that Transformer[A, B] <: Transformer.Derived[A, B] so any Transformer in scope is eligible to become a Transformer.Derived.

final case class Source[A](field1: Int, field2: String, generic: A)
final case class Dest[A](field1: Int, field2: String, generic: A)

def transformSource[A, B](source: Source[A])(using Transformer.Derived[A, B]): Dest[B] = source.to[Dest[B]]

transformSource[Int, Option[Int]](Source(1, "2", 3))
// res21: Dest[Option[Int]] = Dest(
//   field1 = 1,
//   field2 = "2",
//   generic = Some(value = 3)
// )
{
  def transformSource[A, B](source: Source[A])(using x$2: Transformer.Derived[A, B]): Dest[B] =
    (new Dest[B](field1 = source.field1, field2 = source.field2, generic = x$2.transform(source.generic)): Dest[B]): Dest[B]
  transformSource[Int, Option[Int]](Source.apply[Int](1, "2", 3))(
    new FromFunction[Int, Option[Int]]((value: Int) => Some.apply[Int](value): Option[Int]): Derived[Int, Option[Int]]
  )
}