purescript
datatype-generics
typelevel

Converting types you don't want to ones you do in Purescript

Update

As of PureScript 0.12, we no longer need any of these tricks as Generics-Rep will not derive or use Record and Field, which is good since we can do everything with records directly using RowToList!


Recently, I've been writing a lot of code that uses information from the type level to do lots of generic operations. It's been a lot of fun, but I've found that there's not really enough out there to explain how people might work with types at the type level.

Example problem

While working on my purescript-bundaegi demo, I ran into the problem that even though I already wrote instances to work with Record row, I also needed to write some functions that worked on Generic Reps. Generic Reps produce a record structure represented with a Rec constructor with fields that Products of Field. For example,

data Person = Person 
  { first   :: String
  , last    :: String
  , address :: Address
  }
derive instance genericPerson :: Generic Person _

Has the type-level Generic Rep of

Constructor
  "Person"
  (Rec
    (Product
      (Field "address" Address)
      (Product
        (Field "first" String)
        (Field "last" String))))

But I need to convert Rec fields into Record row somehow if I want to reuse my record instances. How?

Define a typeclass!

We can define a typeclass for exactly this like so:

class FieldsToRow fields (row :: # Type)

Such that for any given type, we can get the corresponding row type. "Wait, this can't work", you might say, but I have code that does exactly this:

-- type class for a generic rep having a Typescript representation
instance recHasTSRep ::
  ( FieldsToRow fields row
  , HasTSRep (Record row)
  ) => HasTSRep (Rec fields) where
  -- ...

Writing our instances

Since we know that the only types we want to handle are Field and Product, this gets a lot easier than one might initially think. For the Product case, the instance can be defined like so:

instance productFieldsToRow ::
  ( FieldsToRow a l
  , FieldsToRow b r
  , Union l r row
  ) => FieldsToRow (Product a b) row

From the left side, we're able to get the row type, and the same for the right. And then we use the Union class to union the rows together.

Then we have our Field instance:

instance fieldFieldsToRow ::
  ( RowCons name ty () row
  ) => FieldsToRow (Field name ty) row

...which uses the RowCons class to create singleton rows by adding our field name :: ty to an empty row.

That's it!

Another example

I also had a case where I needed to convert a generics-rep Sum and its Constructors to a RowList. This also ends up being a matter of defining a class and some instances:

class GenericSumToRowList a (rl :: RowList)
instance sumGenericSumToRowList ::
  ( GenericSumToRowList a l
  , GenericSumToRowList b r
  , RowListAppend l r rl
  ) => GenericSumToRowList (Sum a b) rl

...where RowListAppend is a class Liam Goodacre wrote for appending two RowLists.

As for the Constructor case:

instance constructorGenericSumToRowList ::
  ( TypeEquals (RLProxy (Cons name ty Nil)) (RLProxy rl)
  ) => GenericSumToRowList (Constructor name ty) rl

In our instance here, I created a RowList with a single element by using the Cons data type similar to the FieldsToRow base case. To then equate this to rl, I used RLProxy to make Types out of the row lists so I could use TypeEquals for equality. Typeclasses all the way down!

Conclusion

Hopefully, this post has shown you that type classes can convert types you don't want to use to ones that you do want to use, and that you can define your own typeclasses to do conversions as you need.

Links