Toby Hobson

Copying fields using Shapeless - Part 2

banner.png
Copying fields using Shapeless - Part 2
Estimated reading time: 5 minutes

In my first post I explained how shapeless can

  1. Transform a case class to an Hlist
  2. Concatenate hlists
  3. Transform an hlist back to another case class

There are some serious limitations to our previous implementation:

We need to ensure the case class fields are ordered correctly. If the order of the fields change the code will either not compile (with LabelledGeneric); or worse it may compile and run but return the wrong date. We really shouldn’t need to worry about the ordering of case class fields

If the Location, Vehicle and Driver case classes contain too many fields it also won’t compile. Equally, we assume all the information needed to build a Reservation instance is contained in the 3 case classes we merge. If a field is missing it won’t compile

The updated ADTs

To follow along it’s best to check out the accompanying source code from my Github repo and checkout the 6-updated-adts tag

In this post I will address all these shortcomings. Let’s assume that our location class now looks like this (note how I swapped the order of the last two fields):

case class Location(pickup: String, dropOff: String, to: LocalDate, from: LocalDate)

Our Car now includes some additional fields not needed to build a reservation:

case class Vehicle(vehicleCategory: String, automatic: Boolean, numDoors: Int, isDiesel: Boolean)

Finally we’ll add an additional field to our Reservation class:

case class Reservation(
  pickup: String,
  dropOff: String,
  from: LocalDate,
  to: LocalDate,
  vehicleCategory: String,
  automatic: Boolean,
  numDoors: Int,
  driverAge: Int,
  nationality: String,
  confirmed: Boolean // this is new
)

We can see our previous code now doesn’t even compile, we’ll fix it step by step.

Dropping a field

To follow along, checkout the 7-pending-alignment tags

Let’s drop the isDiesel field from the Vehicle as we dont need it:

First we need an additional import import shapeless.record._, Once we have this we can drop the isDiesel field from the generated vehicle hlist:

val (_, vehicleRepr) = VehicleGen.to(vehicle).remove('isDiesel) 

remove returns the element that was removed and the updated hlist as a tuple. Notice how shapeless uses a symbol and this operation is also checked at compile time. If we try to remove a non existent field the compiler will complain:

val (_, vehicleRepr) = VehicleGen.to(vehicle).remove('isPetrol)
Error:(20, 66) No field Symbol with shapeless.tag.Tagged[String("isPetrol")] in record ...

Adding a field

We also need to add the confirmed flag to the final hlist before converting to a Reservation instance. Again we start with another import: import shapeless.syntax.singleton._

Ad hoc fields are created using a (fieldName ->> value) syntax. Once we have our field we can add it to the hlist:

val misalignedReservationRepr = locationRepr ++ vehicleRepr ++ driverRepr :+ ('confirmed ->> true)

At this point we have all the fields we need to create a Reservation but they’re in the wrong order.

Aligning the types

To follow along, checkout the 8-aligned-fields tags

We can ask Shapeless to align the fields so the hlist is in the correct order to build a Reservation. Conceptually this is quite simple but we need to do a few tricks to let the compiler infer the HList types for us.

Shapeless includes a type class called Align which is parameterized on the source and target HList types. i.e. to summon an instance we should use Align[A, B] where A is the type of misalignedReservationRepr and B is ReservationGen.Repr. But what is “the type of misalignedReservationRepr”? We know what is is, but we don’t have a static type or alias for it. We’ll use a trick to infer this for us:

object ShapelessOps {

  import shapeless._
  import shapeless.ops.hlist
 
  // 1
  implicit class AlignerOps[ARepr <: HList](a: ARepr) {
    def as[B](implicit aligner: Aligner[ARepr, B]): B = aligner.apply(a)
  }

  // 2
  trait Aligner[A, B] {
    def apply(a: A): B
  }

  // 3
  implicit def genericAligner[ARepr <: HList, B, BRepr <: HList](
    implicit
    bGen    : LabelledGeneric.Aux[B, BRepr],
    align   : hlist.Align[ARepr, BRepr]
  ): Aligner[ARepr, B] = new Aligner[ARepr, B] {
    def apply(a: ARepr): B = bGen.from(align.apply(a))
  }

}

Let’s decompose this:

  1. We create an implicit class to “pimp” an HList, adding an as[B] method. This method requires an implicit Aligner instance that we define

  2. Our Aligner trait simply says convert from A to B. So far there’s nothing interesting happening. The real magic happens in our genericAligner method:

  3. Our genericAligner method uses a shapeless Align instance to perform the alignment and therefore build an Aligner. We have the source and target types (ARepr, BRepr and B) so we can summon an instance of Align

We can now transform our misalignedReservationRepr to a Reservation by calling .as[Reservation] on it

The final implementation

So our final implementation looks like this:

object ShapelessOps {

  import shapeless._
  import shapeless.ops.hlist

  // 1
  implicit class AlignerOps[ARepr <: HList](a: ARepr) {
    def as[B](implicit aligner: Aligner[ARepr, B]): B = aligner.apply(a)
  }

  // 2
  trait Aligner[A, B] {
    def apply(a: A): B
  }

  // 3
  implicit def genericAligner[ARepr <: HList, B, BRepr <: HList](
    implicit
    bGen    : LabelledGeneric.Aux[B, BRepr],
    align   : hlist.Align[ARepr, BRepr]
  ): Aligner[ARepr, B] = new Aligner[ARepr, B] {
    def apply(a: ARepr): B = bGen.from(align.apply(a))
  }

}

object Main {

  import java.time.LocalDate
  import shapeless.LabelledGeneric
  import shapeless.record._
  import shapeless.syntax.singleton._

  def main(args: Array[String]): Unit = {
    import ShapelessOps._

    // Our case class representations of the data
    // See the updated case class declaration for Location
    val location = Location(pickup = "Malaga Airport", dropOff = "Malaga Airport", from = LocalDate.of(2018,8,1), to = LocalDate.of(2018,8,10))
    val vehicle = Vehicle(vehicleCategory = "Economy", automatic = false, numDoors = 4, isDiesel = true)
    val driver = Driver(driverAge = 35, nationality = "British")

    val LocationGen = LabelledGeneric[Location]
    val VehicleGen = LabelledGeneric[Vehicle]
    val DriverGen = LabelledGeneric[Driver]
    val ReservationGen = LabelledGeneric[Reservation]

    val locationRepr = LocationGen.to(location)
    val (_, vehicleRepr) = VehicleGen.to(vehicle).remove('isDiesel)
    val driverRepr = DriverGen.to(driver)

    val misalignedReservationRepr = locationRepr ++ vehicleRepr ++ driverRepr :+ ('confirmed ->> true)

    // closer!
    /*_*/ val reservation: Reservation = misalignedReservationRepr.as[Reservation] /*_*/
    println(s"from date: ${reservation.from}")
  }

}

There’s still much more we can do. The code is not as generic as it could be and it’s pretty verbose. In the final post in this series I will clean up the code

comments powered by Disqus

Need help with your project?

Do you need some help or guidance with your project? Reach out to me (email is best)