Working with Inferred Record Types¶
How records work in PureScript¶
In PureScript, a Record
type is parameterized by # Type
data Record :: # Type -> Type
As seen on Pursuit, this means that records are an application of row types of Type
, such that the two definitions are equivalent:
type Person = { name :: String, age :: Number }
type Person = Record ( name :: String, age :: Number )
With this knowledge, we can work with records in a generic way where any operation with the correct row type constraints is valid.
This is unlike other languages where records are often simply product types with selector information. Let’s look at some examples of this at work.
Modifying a field’s type¶
Say that we wanted to read in JSON into this type:
type RecordWithEither =
{ apple :: Int
, banana :: Either Int String
}
We know that there’s no representation of this Either Int String
in JavaScript, but it would be convenient to read some value into it. First, let’s define a function to read in any Either
:
readEitherImpl
:: forall a b
. JSON.ReadForeign a
=> JSON.ReadForeign b
=> Foreign
-> Foreign.F (Either a b)
readEitherImpl f
= Left <$> JSON.readImpl f
<|> Right <$> JSON.readImpl f
Now we can read in to an Either
any a
and b
that have instances for ReadForeign
. We can then use this to modify a field in an inferred context:
readRecordWithEitherJSON :: String -> Either Foreign.MultipleErrors RecordWithEither
readRecordWithEitherJSON s = runExcept do
inter <- JSON.readJSON' s
banana <- readEitherImpl inter.banana
pure $ inter { banana = banana }
So what goes on here is that since the result of the function is our RecordWithEither
with a field of banana :: Either Int String
, the type is inferred “going backwards”, so with the application of our function that is now concretely typed in this context as readEitherImpl :: Foreign -> Foreign.F (Either Int String)
, the inter
is read in as { apple :: Int, banana :: Foreign }
.
In this case, we used record update syntax to modify our inferred record, but we also could have done this generically using Record.modify
from the Record library.
PureScript-Record in a nutshell¶
Most of PureScript-Record revolves around usages of two row type classes from Prim.Row:
class Cons
(label :: Symbol) (a :: Type) (tail :: # Type) (row :: # Type)
| label a tail -> row, label row -> a tail
class Lacks
(label :: Symbol) (row :: # Type)
class Cons
is a relation of a field of a given Symbol
label (think type-level String
), its value Type
, a row type tail
, and a row type row
which is made of the tail
and the field put together. This is very much like your normal List
of Cons a
and Nil
, but with the unordered row type structure at the type level (that (a :: String, b :: Int)
is equivalent to (b :: Int, a :: String)
).
class Lacks
is a relation of a given Symbol
label not existing in any of the fields of row
.
With this bit of knowledge, we can go ahead and look at the docs of the Record library.
Let’s go through a few of these. First, get
:
get
:: forall r r' l a
. IsSymbol l
=> Cons l a r' r
=> SProxy l
-> { | r }
-> a
So here right away we can see that the Cons
constraint is used to declare that the label l
provided by the SProxy
argument must exist in the row type r
, and that there exists a r'
, a complementary row type, which is r
but without the field l, a
. With this, this function is able to get out the value of type a
at label l
. This function doesn’t know what concrete label is going to be used, but it uses this constraint to ensure that the field exists in the record.
insert
:: forall r1 r2 l a
. IsSymbol l
=> Lacks l r1
=> Cons l a r1 r2
=> SProxy l
-> a
-> { | r1 }
-> { | r2 }
With insert
, we work with the input row type r1
and the output row type r2
. The constraints here work that the r1
row should not contain a field with label l
, and that the result of adding a field of l, a
to r1
yields r2
.
Now, the most involved example:
rename
:: forall prev next ty input inter output
. IsSymbol prev
=> IsSymbol next
=> Cons prev ty inter input
=> Lacks prev inter
=> Cons next ty inter output
=> Lacks next inter
=> SProxy prev
-> SProxy next
-> { | input }
-> { | output }
Because PureScript does not solve multiple constraints simultaneously, we work with three row types here: input
, inter
(intermediate), and output
. This function takes two Symbol
types: one for the current label of the field and one for the next label. Then the constraints work such that inter
is input
without the field prev, ty
and lacks any additional fields of prev
, as row types can have duplicate labels as they are not only for records. Then output
is constructured by adding the field next, ty
to inter
and checking that the inter
does not already contain a field with the label next
. While this seems complicated at first, slowly reading through the constraints will show that this is a series of piecewise operations instead of being a multiple-constraint system.
Application of generic Record functions¶
Say we have a type where we know the JSON will have the wrong name:
type RecordMisnamedField =
{ cherry :: Int
}
If the JSON we receive has this field but with the name “grape”, what should we do?
We can apply the same inferred record type method as above but with Record.rename
:
readRecordMisnamedField :: String -> Either Foreign.MultipleErrors RecordMisnamedField
readRecordMisnamedField s = do
inter <- JSON.readJSON s
pure $ Record.rename grapeP cherryP inter
where
grapeP = SProxy :: SProxy "grape"
cherryP = SProxy :: SProxy "cherry"
So again, by applying a function that renames grape, Int
to cherry, Int
, the inferred record type of the inter
is { grape :: Int }
and that is the type used to decode the JSON.
Hopefully this page has shown you how powerful row type based Records are in PureScript and the generic operations they allow.
You might be interested in reading through slides for further illustrations of how generic record operations work and how they can be used with Simple-JSON.