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 thatl
should be aSymbol
label -
RowLacks l r1
guarantees that the row typer1
doesn't already have a labell
. -
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
- This repo: https://github.com/justinwoo/purescript-simple-json
- Purescript-Record: https://github.com/purescript/purescript-record
- More about
RowToList
: https://www.reddit.com/r/purescript/comments/6mss5o/new_in_purescript_0116_rowtolist/