Jacek's Blog

Software Engineering Consultant

Simple(r) Heterogeneous Mapping of Records in Purescript

February 13, 2023

Two weeks ago, I blogged about the automatic heterogeneous zipping of records in PureScript. The code was longer than the manual version but at least less error-prone. After learning how to do it this way, I present a neat little library (not mine) that provides all the benefits without the downsides.

Let’s have a look again at the binary function lerp n which takes two numeric values and returns the linear interpolation of them. We defined it for multiple types:

class Lerp a where
lerp :: Number -> a -> a -> a

instance lerpNumber :: Lerp Number where
lerp n a b = a + (b - a) * n

instance lerpInt :: Lerp Int where
lerp n a b = round \$ lerp n (toNumber a) (toNumber b)

-- ... and a few more instances ...

This was the necessary portion of domain-specific code, I only omitted the instances for Boolean and Array.

Now, we can write lerp 0.5 a b for two values a and b if they are of type Number, Int. To be able to lerp a whole set of values that are bundled in two records, we implemented a type class LerpRecord that recursively maps the lerp function over all record members of any generic record type. Then, we used its function lerpRecord to create an instance of the Lerp class for such generic record types.

This was great for learning but too much code. Instead, we can throw all the complicated code away and use the purescript-heterogeneous library:

import Heterogeneous.Mapping as HM

data LerpMapping items = LerpMapping Number { | items }

instance lerpMappingWithIndex ::
( IsSymbol sym
, Row.Cons sym a x as
, Lerp a
) =>
HM.MappingWithIndex (LerpMapping as) (Proxy sym) a a where
mappingWithIndex (LerpMapping n as) prop = lerp n (R.get prop as)

To lerp a record of values, we need to define this operation as an instance of the class MappingWithIndex from the purescript-heterogeneous library. For this, we can’t just take the function lerp and provide it as a runtime parameter, because the knowledge that this is an operation for which every item must have an instance in the Lerp type class is needed at compile time. To provide this information at compile time, we define the data type LerpMapping. Using that type in the type class instance of MappingWithIndex, the compiler gets all the information about lerping that it needs to create all the type class instances that we wrote by hand two weeks ago.

Users will not have to know about this instance, as they just want to call lerp on records, which we enable with the Lerp type class like this:

instance lerpRecordInstance ::
( RL.RowToList a t
, HM.MapRecordWithIndex t (LerpMapping a) a a
) =>
Lerp (Record a) where
lerp n = HM.hmapWithIndex <<< LerpMapping n

Without changing the unit tests, all test cases pass with green colors after throwing away most of the code!

One complicated detail here is that the first record goes into the function via the LerpMapping type constructor, while the second record is an actual parameter of the hmapWithIndex function. The way this works is that the mappingWithIndex function uses the record from the LerpMapping type as a lookup table so to say: While it iterates over all values of the second record, it uses the current value’s key name to look up the first value from the first record and then lerps them both together.

The full code is in a branch of the original repo from the last article.

Summary

Writing code that maps polymorphic functions over heterogeneous records is not too hard and reduces error potential. The last article, where we did this by hand, has shown that this is not always worth the hassle when looking at the big increase in code lines.

Using the purescript-heterogeneous library, it’s suddenly very easy to provide type class instances for random functions over generic records. In general, this is a bit easier in PureScript compared to Haskell, because PureScript has Row Types.