Previously, I talked about how we can use RowToList in Purescript to work with row types of records by converting them to type-level lists here. But the actual implementation was a little gory and maybe brittle -- it relies on implementation details of StrMap and loses type safety in that it ignores the requirement of unique keys and the type of the individual fields. While we can write tests against our implementation, unsafe coercion of data doesn't really give us the most confidence.

Thankfully, Purescript-Record gives us all the type-safe operations on records with proper typings that we want. I recently went through and fixed the implementation of record building in Simple-JSON to take advantage of this library.

Some Background

The Simple-JSON library has one main typeclass that it provides implementations to the core data structures for with this signature:

class ReadForeign a where readImpl :: Foreign -> F a

Where F a is an alias for Except MultipleErrors a . The typical instance then looks the following:

instance readString :: ReadForeign String where readImpl = readString

Where readString is from the Foreign library.

The Wrong Way

The previous method would then define an instance for Record in this way:

instance readRecord :: ( RowToList fields fieldList , ReadForeignFields fieldList , ListToRow fieldList fields ) => ReadForeign ( Record fields ) where -- ...

By using RowToList and ListToRow , we can extract and constrain our type-level list of our record fields row type. Then we would use a ReadForeignFields class defined as such:

class ReadForeignFields ( xs :: RowList ) where getFields :: RLProxy xs -> Foreign -> F ( StrMap Foreign )

Where RLProxy (RowList Proxy) is a Proxy data type for our type-level list extracted from our row type. But here, we have the return type as StrMap Foreign , meaning that we are parsing to a StrMap , which is only a Record by an implementation detail, of Foreign , or a kind of any JS value. The implementation of readImpl for the above instance then looks like this:

readImpl = unsafeCoerce $ getFields ( RLProxy :: RLProxy fieldList )

Which is not the greatest. The implementations also involve creating an empty StrMap and making unions with singleton StrMap . Technically correct, but doesn't have many of the characteristics that we actually want. Good news is that we can solve this with Purescript-Record operations and get all the characteristics we want.

Purescript-Record in a nutshell

While we could work with specific record types and add specific fields, we need a solution that works for all records where we can add any label of any type. Purescript-Record gives us exactly that, of which we really only need one method, insert:

insert :: forall r1 r2 l a . IsSymbol l => RowLacks l r1 => RowCons l a r1 r2 => SProxy l -> a -> { | r1 } -> { | r2 }

This is fairly involved, but the individual parts all come with their own documentation.

IsSymbol l just adds the constraint that l should be a Symbol label

just adds the constraint that should be a label RowLacks l r1 guarantees that the row type r1 doesn't already have a label l .

guarantees that the row type doesn't already have a label . RowCons l a r1 r2 is a feature available from the compiler that will take a label, a type, and a row to then add the label :: type field into the row to produce the result row.

That's about it. It then takes a SProxy ("string" Proxy) of our label, a value of our type, and a record that has the constraints to add this field into the result record.

Putting this to work

There is one more minor detail to talk about: the relationship between a given row type and row-list can't be solved, so we do need to change our ReadForeignFields class accordingly:

class ReadForeignFields ( xs :: RowList ) ( row :: # Type ) where getFields :: RLProxy xs -> RProxy row -> Foreign -> F ( Record row )

So the difference from above is that we don't need to extract the row type from the row-list here, as we pass in the row type through a RProxy (row Proxy). Our instance for the row-list elements ends up being quite straightforward:

instance readFieldsCons :: ( IsSymbol name , ReadForeign ty , ReadForeignFields tail tailRow , RowLacks name tailRow , RowCons name ty tailRow row ) => ReadForeignFields ( Cons name ty tail ) row where getFields _ _ obj = do value <- readImpl =<< readProp name obj rest <- getFields tailP tailRowP obj pure $ insert nameP value rest where nameP = SProxy :: SProxy name tailP = RLProxy :: RLProxy tail tailRowP = RProxy :: RProxy tailRow name = reflectSymbol nameP

Our row-list Cons consists of the label/name and type of the field and the remaining elements of the list. In using the insert method from above, we add constraints for IsSymbol name along with RowLacks name tailRow and RowCons name ty tailRow row appropriately, where tailRow is introduced by our ReadForeignFields tail tailRow constraint, thus, extracting out the row type from our tail row-list. The ReadForeign ty constraint simply makes sure that we are able to read out the value from the type.

For our Nil instance, however, we need to add a helper instance:

instance readFieldsNil :: ( TypeEquals {} ( Record row ) ) => ReadForeignFields Nil row where getFields _ _ _ = pure $ to {}

Because a normal {} by itself doesn't necessarily mean its type is equal to Record row , we need to supply a constraint TypeEquals {} (Record row) . To then do the conversion, we use the to method to convert our {} type into a Record row .

And that's about it!

Hopefully this has shown that these type-safe record operations can be quite nice and useful in various ways, and given you some ideas on various record operations you'd also like to perform, both concrete and abstract.

Future work

There has been talk about making a Record.ST module or something to make ST-constrained locally mutating operations readily available for cases like these where you'd like to just modify the records directly. To be continued...

Links