Quick start

Maven Central

ducktape is a library for boilerplate-less and configurable transformations between case classes and enums/sealed traits for Scala 3. Directly inspired by chimney.

If this project interests you, please drop a 🌟 - these things are worthless but give me a dopamine rush nonetheless.

Installation

libraryDependencies += "io.github.arainko" %% "ducktape" % "0.2.6"

// or if you're using Scala.js or Scala Native
libraryDependencies += "io.github.arainko" %%% "ducktape" % "0.2.6"

NOTE: the version scheme is set to early-semver

You're currently browsing the documentation for ducktape 0.2.x, if you're looking for the 0.1.x docs go here: https://github.com/arainko/ducktape/tree/series/0.1.x#-ducktape

Entrypoint of the library

The user-facing API of ducktape is mostly a bunch of extension methods that allow us to transform between types in a variety of ways, the only import needed to get started looks like this:

import io.github.arainko.ducktape.*

Motivating example

ducktape is all about painlessly transforming between similiarly structured case classes/enums/sealed traits. If we were to define two really, really similar sets of case class and/or enums, eg. ones like these:

import java.time.Instant
import io.github.arainko.ducktape.*

object wire:
  final case class Person(
    firstName: String,
    lastName: String,
    paymentMethods: List[wire.PaymentMethod],
    status: wire.Status,
    updatedAt: Option[Instant]
  )

  enum Status:
    case Registered, PendingRegistration, Removed

  enum PaymentMethod:
    case Card(name: String, digits: Long, expires: Instant)
    case PayPal(email: String)
    case Cash
import java.time.Instant
import io.github.arainko.ducktape.*

object domain:
  final case class Person( // <-- fields reshuffled
    lastName: String,
    firstName: String,
    status: Option[domain.Status], // <-- 'status' in the domain model is optional
    paymentMethods: Vector[domain.Payment], // <-- collection type changed from a List to a Vector
    updatedAt: Option[Instant]
  )

  enum Status:
    case Registered, PendingRegistration, Removed
    case PendingRemoval // <-- additional enum case

  enum Payment:
    case Card(name: String, digits: Long, expires: Instant)
    case PayPal(email: String)
    case Cash

...and an input instance that we intend to transform into its domain counterpart:

val wirePerson: wire.Person = wire.Person(
  "John",
  "Doe",
  List(
    wire.PaymentMethod.Cash,
    wire.PaymentMethod.PayPal("john@doe.com"),
    wire.PaymentMethod.Card("J. Doe", 12345, Instant.now)
  ),
  wire.Status.PendingRegistration,
  Some(Instant.ofEpochSecond(0))
)

...then transforming between the wire and domain models is just a matter of calling .to[domain.Person] on the input:

wirePerson.to[domain.Person]
// res0: Person = Person(
//   lastName = "Doe",
//   firstName = "John",
//   status = Some(value = PendingRegistration),
//   paymentMethods = Vector(
//     Cash,
//     PayPal(email = "john@doe.com"),
//     Card(
//       name = "J. Doe",
//       digits = 12345L,
//       expires = 2024-11-16T09:57:08.195468774Z
//     )
//   ),
//   updatedAt = Some(value = 1970-01-01T00:00:00Z)
// )
((new Person(
  lastName = MdocApp.this.wirePerson.lastName,
  firstName = MdocApp.this.wirePerson.firstName,
  status = Some.apply[Status](
    if MdocApp.this.wirePerson.status.isInstanceOf[Registered.type] then MdocApp.this.domain.Status.Registered
    else if MdocApp.this.wirePerson.status.isInstanceOf[PendingRegistration.type] then
      MdocApp.this.domain.Status.PendingRegistration
    else if MdocApp.this.wirePerson.status.isInstanceOf[Removed.type] then MdocApp.this.domain.Status.Removed
    else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
  ),
  paymentMethods = MdocApp.this.wirePerson.paymentMethods
    .map[Payment]((src: PaymentMethod) =>
      if src.isInstanceOf[Card] then
        new Card(
          name = src.asInstanceOf[Card].name,
          digits = src.asInstanceOf[Card].digits,
          expires = src.asInstanceOf[Card].expires
        )
      else if src.isInstanceOf[PayPal] then new PayPal(email = src.asInstanceOf[PayPal].email)
      else if src.isInstanceOf[Cash.type] then MdocApp.this.domain.Payment.Cash
      else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
    )
    .to[Vector[Payment]](iterableFactory[Payment]),
  updatedAt = MdocApp.this.wirePerson.updatedAt
): Person): Person)

But now imagine that your wire model differs ever so slightly from your domain model, maybe the wire model's PaymentMethod.Card doesn't have the name field for some inexplicable reason...

import java.time.Instant
import io.github.arainko.ducktape.*

object wire:
  final case class Person(
    firstName: String,
    lastName: String,
    paymentMethods: List[wire.PaymentMethod],
    status: wire.Status,
    updatedAt: Option[Instant]
  )

  enum Status:
    case Registered, PendingRegistration, Removed

  enum PaymentMethod:
    case Card(digits: Long, expires: Instant) // <-- poof, 'name' is gone
    case PayPal(email: String)
    case Cash
import java.time.Instant
import io.github.arainko.ducktape.*

object domain:
  final case class Person(
    lastName: String,
    firstName: String,
    status: Option[domain.Status],
    paymentMethods: Vector[domain.Payment],
    updatedAt: Option[Instant]
  )

  enum Status:
    case Registered, PendingRegistration, Removed
    case PendingRemoval

  enum Payment:
    case Card(name: String, digits: Long, expires: Instant)
    case PayPal(email: String)
    case Cash

...and when you try to transform between these two representations the compiler now yells at you.

val domainPerson = wirePerson.to[domain.Person]
// error:
// No field 'name' found in MdocApp0.this.wire.PaymentMethod.Card @ Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name
// val domainPerson = wirePerson.to[domain.Person]
//                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Now onto dealing with that, let's first examine the error message:

No field 'name' found in MdocApp0.this.wire.PaymentMethod.Card @ Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name

especially the part after @:

Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name

the thing above is basically a path to the field/subtype under which ducktape was not able to create a transformation, these are meant to be copy-pastable for when you're actually trying to fix the error, eg. by setting the name field to a constant value:

val domainPerson =
  wirePerson
    .into[domain.Person]
    .transform(Field.const(_.paymentMethods.element.at[domain.Payment.Card].name, "CONST NAME"))
// domainPerson: Person = Person(
//   lastName = "Doe",
//   firstName = "John",
//   status = Some(value = PendingRegistration),
//   paymentMethods = Vector(
//     Cash,
//     PayPal(email = "john@doe.com"),
//     Card(
//       name = "CONST NAME",
//       digits = 12345L,
//       expires = 2024-11-16T09:57:08.202723300Z
//     )
//   ),
//   updatedAt = Some(value = 1970-01-01T00:00:00Z)
// )
{
  val AppliedBuilder_this: AppliedBuilder[Person, Person] = into[Person](MdocApp2.this.wirePerson1)[MdocApp2.this.domain.Person]
  {
    val value$proxy3: Person = AppliedBuilder_this.inline$value
    new Person(
      lastName = value$proxy3.lastName,
      firstName = value$proxy3.firstName,
      status = Some.apply[Status](
        if value$proxy3.status.isInstanceOf[Registered.type] then MdocApp2.this.domain.Status.Registered
        else if value$proxy3.status.isInstanceOf[PendingRegistration.type] then MdocApp2.this.domain.Status.PendingRegistration
        else if value$proxy3.status.isInstanceOf[Removed.type] then MdocApp2.this.domain.Status.Removed
        else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
      ),
      paymentMethods = value$proxy3.paymentMethods
        .map[Payment]((src: PaymentMethod) =>
          if src.isInstanceOf[Card] then
            new Card(name = "CONST NAME", digits = src.asInstanceOf[Card].digits, expires = src.asInstanceOf[Card].expires)
          else if src.isInstanceOf[PayPal] then new PayPal(email = src.asInstanceOf[PayPal].email)
          else if src.isInstanceOf[Cash.type] then MdocApp2.this.domain.Payment.Cash
          else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
        )
        .to[Vector[Payment]](iterableFactory[Payment]),
      updatedAt = value$proxy3.updatedAt
    ): Person
  }: Person
}

Read more in the chapter dedicated to configuring transformations.

To get an idea of what transformations are actually supported head on over to transformation rules.