
Writing a Scala object mapping library – Part 2: Shapeless
I’ve been writing an object mapping library in Scala for fun. In the second part of this blog series I’ll be examining if using shapeless was the right choice.
In the introduction to this blog series, I explored how you could create object mapping code using Scala. Recap: using a simple approach seems to be unmaintainable, because the amount of mapping code increases rapidly depending on two factors:
- The size of your data types (the quality axis)
- The number of data types (the quantity axis)
In this part of the series, we try a different approach.
Possible solution #2: shapeless’ LabelledGeneric
At this point I started thinking of using an external library, but to keep myself challenged, a library that already does all the mapping was not something I was looking for. A quick search for such a library didn’t give me much anyway, apart from maybe this Stack Overflow question. So time to get back to typing.
I already mentioned the shapeless library in the introduction. It is described as Generic programming for Scala, so that promises a nice base for a generic solution to the boilerplate problem.
I won’t dive deeply into the magical world of shapeless, but one of the features the library offers is converting Scala case class instances to HList
instances. A HList
is a datatype for heterogenous lists of fixed length, meaning you can have a list containing elements of different types. For instance:
val fooList: Int :: String :: HNil = 1 :: "quux" :: HNil // HNil is to HList what Nil is to List |
These HList
instances can be used to represent arguments to a function. Since a case class constructor is a function, you can use HList
instances as a projection of a case class constructor call, and vice versa. The shapeless library offers this through the use of Generic
:
import shapeless.Generic | |
case class Foo(intField: Int, stringField: String) | |
case class Bar(intField: Int, stringField: String) | |
val fooGeneric = Generic[Foo] | |
val barGeneric = Generic[Bar] | |
val foo = Foo(1, "quux") | |
// OK, cool, give me the arguments passed to the constructor call that created foo | |
val fooArgs = fooGeneric.to(foo) | |
// Now give me a Bar from those arguments | |
val bar = barGeneric.from(fooArgs) // Bar(1, "quux") |
Okay, that’s nice. But one thing, field names are ignored. Not of much use when you want to map case class fields based on name. The code below will work, but does not give the desired result; the fields are mapped regardless of field name:
import shapeless.Generic | |
case class Foo(intField: Int, stringField: String) | |
case class Baz(thisIsNotAnIntFieldOhButActuallyItIs: Int, struungFuuld: String) | |
val fooGeneric = Generic[Foo] | |
val bazGeneric = Generic[Baz] | |
val foo = Foo(1, "quux") | |
// OK, cool, give me the arguments passed to the constructor call that created foo | |
val fooArgs = fooGeneric.to(foo) | |
// Now give me a bar from those arguments | |
val baz = bazGeneric.from(fooArgs) // Baz(1, "quux") |
And another thing, it requires the two separate case classes to have fields of the same type in the same order. So this will not work:
import shapeless.Generic | |
case class Foo(intField: Int, stringField: String) | |
case class Bar(stringField: String, intField: Int) // My field are swapped | |
val fooGeneric = Generic[Foo] | |
val barGeneric = Generic[Bar] | |
val foo = Foo(1, "quux") | |
// OK, cool, give me the arguments passed to the constructor call that created foo | |
val fooArgs = fooGeneric.to(foo) | |
// Now give me a Bar from those arguments | |
val bar = barGeneric.from(fooArgs) |
It will fail during compilation:
// taken from an ammonite session | |
cmd30.sc:1: type mismatch; | |
found : $sess.cmd26.fooGeneric.Repr | |
(which expands to) shapeless.::[Int,shapeless.::[String,shapeless.HNil]] | |
required: $sess.cmd27.barGeneric.Repr | |
(which expands to) shapeless.::[String,shapeless.::[Int,shapeless.HNil]] | |
val bar = barGeneric.from(fooArgs) | |
^ | |
Compilation Failed |
See those expanded types in the compilation error? The HList
representation of Foo
is Int :: String :: HNil
while the one for Bar
is String :: Int :: HNil
. Type mismatch. Compilation failed. Sad face 🙁
Label me…
There’s another tool in the shapeless box that’s a better candidate for name-based field mapping, called LabelledGeneric
. What it does is almost the same as Generic
, but with the added nicety of tagging a field name to the element types in the HList
representation of case class fields. It does so through some macro magic that inspects the case class type during compilation. You can use it like this:
import shapeless.LabelledGeneric | |
case class Foo(intField: Int, stringField: String) | |
case class Bar(stringField: String, intField: Int) // My field are swapped | |
val fooGeneric = LabelledGeneric[Foo] | |
val barGeneric = LabelledGeneric[Bar] |
But still, when the code continues like below…
val foo = Foo(1, "quux") | |
// OK, cool, give me the arguments passed to the constructor call that created foo | |
val fooArgs = fooGeneric.to(foo) | |
// Now give me a Bar from those arguments | |
val bar = barGeneric.from(fooArgs) |
Failure. Very bad.
// taken from an ammonite session | |
cmd38.sc:1: type mismatch; | |
found : $sess.cmd34.fooGeneric.Repr | |
(which expands to) shapeless.::[Int with shapeless.labelled.KeyTag[Symbol with shapeless.tag.Tagged[String("intField")],Int],shapeless.::[String with shapeless.labelled.KeyTag[Symbol with shapeless.tag.Tagged[String("stringField")],String],shapeless.HNil]] | |
required: $sess.cmd35.barGeneric.Repr | |
(which expands to) shapeless.::[String with shapeless.labelled.KeyTag[Symbol with shapeless.tag.Tagged[String("stringField")],String],shapeless.::[Int with shapeless.labelled.KeyTag[Symbol with shapeless.tag.Tagged[String("intField")],Int],shapeless.HNil]] | |
val bar = barGeneric.from(fooArgs) | |
^ | |
Compilation Failed |
Type mismatch. Compilation failed. Sad face 🙁
What to do? Well…
It’s turtles implicits all the way down
Ideally, for mapping objects, I’d like to have a nice syntax. Something like Foo(1,
. It should be available not only for
"quux").mappedTo[Bar]Foo
instances, but for other case classes too. Sounds like a task for an implicit class:
package nl.infi.thingamabob.data | |
package object mapping { | |
implicit class MappedToSyntax[Source](val sourceInstance: Source) extends AnyVal { | |
def mappedTo[Target] = ??? // uhmm.... | |
} | |
} |
Now, what is needed to map one case class instance to an instance of another case class? Maybe this should be the way to go:
- Get a
HList
representation of theSource
object throughLabelledGeneric[Source]
, let’s call thissourceHList
. It will contain the data, but also information about the field names the separate data elements belong to. - With the help of
LabelledGeneric[Target]
, get the type of theHList
representation of the target case class, let’s call thisTargetRepr
. - For each component type in
TargetRepr
, look up the corresponding element insourceHlist
, and build a value of typeTargetRepr
. Let’s call that valuetargetHList
. - Get a fresh, shiny
Target
object throughLabelledGeneric[Target].from(targetHList)
.
Sounds simple enough:
import shapeless.LabelledGeneric | |
case class Foo(intField: Int, stringField: String) | |
case class Bar(stringField: String, intField: Int) // My field are swapped | |
val foo = Foo(1, "quux") | |
val fooGeneric = LabelledGeneric[Foo] | |
val barGeneric = LabelledGeneric[Bar] | |
val sourceHList = fooGeneric.to(foo) // Step 1 | |
type TargetRepr = barGeneric.Repr // Step 2. This is the type the LabelledGeneric converts to and from | |
val targetHList = ??? // Step 3... Something something sourceHList, TargetRepr | |
val target = barGeneric.from(targetHList) // Step 4 |
So it’s
- Get source representation
- Get target representation type
- ???
- Profit
The tough part in step 3 is that it involves traversing a HList type.
Traversing a list value is simple. Below is a useless function that copies a list in a contrived way. You’d never need something like that in application code (unless you get paid by the line).
def copyList[A](as: List[A]): List[A] = as match { | |
case Nil => Nil | |
case a :: tail => a :: copyList(tail) | |
} |
This is the bread and butter of functional programming in Scala: pattern matching and recursion. Can we use pattern matching and recursion at the type level, at least for shapeless.HList
types? Turns out we can:
import shapeless.{HList, ::, HNil} | |
trait HListCopier[Elements] { | |
// Abstraction of the different cases in the mapList example | |
def apply(elements: Elements): Elements | |
} | |
object HListCopier { | |
// equivalent to case Nil => ... in the copyList example | |
implicit def hNilCopier: HListCopier[HNil] = new HListCopier[HNil] { | |
def apply(elements: HNil): HNil = HNil | |
} | |
// equivalent to case a :: rest => ... in the copyList example | |
implicit def nonEmptyHListCopier[A, Rest <: HList](implicit restCopier: HListCopier[Rest]): HListCopier[A :: Rest] = | |
new HListCopier[A :: Rest] { | |
def apply(elements: A :: Rest): A :: Rest = elements.head :: restCopier(elements.tail) | |
} | |
} | |
object HListCopy { | |
// This is the equivalent of copyList | |
def copyHList[Source <: HList](source: Source)(implicit hListCopier: HListCopier[Source]): Source = | |
hListCopier(source) | |
} |
Whew… It has generics, implicits, and it’s a lot more to type than that copyList()
function. And in the end it doesn’t even do anything useful! I’ll give an overview of what happens when you use the code.
HListCopy.copyHList()
can be called with a HList
instance:
val hlist = 1 :: "quux" :: HNil // Int :: String :: HNil | |
val copy = HListCopy.copyHList(hlist) |
When the compiler encounters the call to .copyHList()
, it notices the absence of the hListCopier
argument in the call. Since it’s an implicit argument, it will try to find the best candidate for that argument with the specified type of HListCopier[Source]
.
In this case, it will look for an instance of HListCopier[Int :: String :: HNil]
. The method the compiler uses to find that implicit is discussed in more depth than I will on Stack Overflow, but it boils down to inserting a call to either HListCopier.nonEmptyHListCopier[A,
or
Rest]HListCopier.hNilCopier
. HListCopier.hNilCopier
will return a HListCopier[HNil]
which doesn’t match the type of HListCopier[Int ::
, so the compiler will try
String :: HNil]HListCopier.nonEmptyHListCopier[A,
. That one promises to return a
Rest]HListCopier[A :: Rest]
which is generic over both the head and the tail of the HList
type, and possibly matches HListCopier[Int
. After this first implicit lookup, the code will be equivalent to this:
:: String :: HNil]
val hlist = 1 :: "quux" :: HNil | |
val copy = HListCopy.copyHList[Int :: String :: HNil](hlist)( | |
HListCopier.nonEmptyHListCopier[Int, String :: HNil] | |
) |
HListCopier.nonEmptyHListCopier[A, Rest]
in turn has its own implicit argument, asking for a HListCopier[Rest]
, which in this case expands to HListCopier[String
. Again, this matches with a call to
:: HNil]HListCopier.nonEmptyHListCopier[String,
:
HNil]
val hlist = 1 :: "quux" :: HNil | |
val copy = HListCopy.copyHList[Int :: String :: HNil](hlist)( | |
HListCopier.nonEmptyHListCopier[Int, String :: HNil]( | |
HListCopier.nonEmptyHListCopier[String, HNil] | |
) | |
) |
Now for the next implicit lookup, the implicit argument for HListCopier[Rest]
expands to HListCopier[HNil]
. We have HListCopier.hNilCopier
which promises to return exactly that, so it will be called:
val hlist = 1 :: "quux" :: HNil | |
val copy = HListCopy.copyHList[Int :: String :: HNil](hlist)( | |
HListCopier.nonEmptyHListCopier[Int, String :: HNil]( | |
HListCopier.nonEmptyHListCopier[String, HNil]( | |
HListCopier.hNilCopier | |
) | |
) | |
) |
At this point, there are no more implicits to look for, and the resulting code typechecks and runs fine.
That’s a lot of work for emulating a call to Predef.identity()
…
But it offers a starting point for the actual field name-based mapper we have as our goal.
Here it is:
import shapeless.{HList, ::, HNil, LabelledGeneric} | |
import shapeless.labelled.{FieldType, field} | |
import shapeless.ops.record.Selector | |
trait FieldsSelector[SourceHList, TargetHList] { | |
def apply(source: SourceHList): TargetHList | |
} | |
object FieldsSelector { | |
implicit def hNilFieldsSelector[SourceHList]: FieldsSelector[SourceHList, HNil] = new FieldsSelector[SourceHList, HNil] { | |
def apply(dontCare: SourceHList): HNil = HNil | |
} | |
implicit def hListFieldsSelector[Value, Key, TargetHListTail <: HList, SourceHList <: HList]( | |
implicit | |
restFieldsSelect: FieldsSelector[SourceHList, TargetHListTail], | |
select: Selector.Aux[SourceHList, Key, Value] // select the value of type Value labelled with Key from a HList of type Source | |
): FieldsSelector[SourceHList, FieldType[Key, Value] :: TargetHListTail] = | |
new FieldsSelector[SourceHList, FieldType[Key, Value] :: TargetHListTail] { | |
def apply(source: SourceHList): FieldType[Key, Value] :: TargetHListTail = | |
field[Key](select(source)) :: restFieldsSelect(source) | |
} | |
} | |
trait CaseClassMap[Source, Target] { | |
def apply(source: Source): Target | |
} | |
object CaseClassMap { | |
implicit def caseClassMap[Source, Target, SourceRepr <: HList, TargetRepr <: HList]( | |
implicit | |
sourceGen: LabelledGeneric.Aux[Source, SourceRepr], | |
targetGen: LabelledGeneric.Aux[Target, TargetRepr], | |
labelledHListMapper: FieldsSelector[SourceRepr, TargetRepr] | |
): CaseClassMap[Source, Target] = new CaseClassMap[Source, Target] { | |
def apply(source: Source): Target = targetGen.from(labelledHListMapper(sourceGen.to(source))) | |
} | |
} | |
object MappedToSyntax { | |
implicit class MappedToSyntax[Source](val source: Source) { | |
def mappedTo[Target](implicit ccMap: CaseClassMap[Source, Target]): Target = ccMap(source) | |
} | |
} |
Now let’s use it:
import MappedToSyntax._ | |
case class Foo(intValue: Int, stringValue: String) | |
case class Bar(stringValue: String, intValue: Int) | |
Foo(1, "quux").mappedTo[Bar] // Bar("quux", 1) |
It compiles, and does what we want, awesome.
But what if we try to use it in a way it isn’t supposed to be used?
import MappedToSyntax._ | |
case class Foo(intField: Int, stringField: String) | |
case class Baz(thisIsNotAnIntFieldOhButActuallyItIs: Int, struungFuuld: String) | |
Foo(1, "quux").mappedTo[Baz] |
Compilation fails, as it should:
Error:(57, 25) could not find implicit value for parameter ccMap: CaseClassMap[Foo,Baz] | |
Foo(1, "quux").mappedTo[Baz] | |
^ |
The downside here is in the error message: could not find implicit value for parameter ccMap: CaseClassMap[Foo,Baz]
. It’s cryptic, not very user-friendly. I’d like to know what exactly is causing the compiler to give up. An error message that informs me about the fields that cannot be mapped would be way better, especially when dealing with mapping many fields.
The next part of this series will discuss how to generate more helpful compiler errors. Stay tuned!