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 Product
s 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 Constructor
s 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 RowList
s.
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 Type
s 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
- My Typescript types + io-ts schema generation demo https://github.com/justinwoo/purescript-bundaegi