Usage with Generics-Rep¶
Motivation¶
If you really want to work with sum types using Simple-JSON, you will have to define instances for your types accordingly. Normally, this would mean that you would have to define a bunch of instances manually. For example,
data IntOrBoolean
= Int Int
| Boolean Boolean
instance readForeign :: JSON.ReadForeign IntOrBoolean where
readImpl f
= Int <$> Foreign.readInt f
<|> Boolean <$> Foreign.readBoolean f
But this ends up with us needing to maintain a mountain of error-prone boilerplate, where we might forget to include a constructor or accidentally have duplicate cases. We should be able to work more generically to write how instances should be created once, and then have all of these instances created for us for free.
This is the idea of using datatype generics, which are provided by the Generics-Rep library in PureScript.
Generics-Rep in short¶
Since what makes Generics-Rep work is in the PureScript compiler as a built-in derivation, you can read through its source to get the gist of it: Link
So once you’ve skimmed through that, let’s first look at class Generic
:
class Generic a rep | a -> rep where
to :: rep -> a
from :: a -> rep
The functional dependencies here declare that instances of Generic
are determined by the type given, so only a
needs to be known to get rep
. Then we have a method for turning the representation into our type with to
and our type into a representation with from
. This means that if we define a function that can produce a F rep
from decoding Foreign
in our JSON.ReadForeign
instances, we can map the to
function to it to get F a
. We’ll see how that works later.
If some of this isn’t familiar to you, you should read about type classes from some source like PureScript By Example
Then, let’s look at some of the most relevant representation types:
-- | A representation for types with multiple constructors.
data Sum a b = Inl a | Inr b
-- | A representation for constructors which includes the data constructor name
-- | as a type-level string.
newtype Constructor (name :: Symbol) a = Constructor a
-- | A representation for an argument in a data constructor.
newtype Argument a = Argument a
These will be the main types that will need to write instances for when we define a type class to do some generic decoding. These correspond to the following parts of a definition:
data Things = Apple Int | Banana String
-- a Sum b
-- e.g. Sum (Inl a) (Inr b)
data Things = Apple Int | Banana String
-- Constructor(name) a
-- e.g. Constructor "Apple" a
data Things = Apple Int | Banana String
-- Argument(a)
-- e.g. Argument Int
This diagram probably won’t be that useful the first time you read it, but you may find it to be nice to return to.
You can read more coherent explanations like in the documentation for GHC Generics in generics-deriving
Applying Generics-Rep to decoding untagged JSON values¶
Let’s revisit the IntOrBoolean
example, but this time by using Generics-Rep.
import Data.Generic.Rep as GR
import Data.Generic.Rep.Show (genericShow)
data IntOrBoolean2
= Int2 Int
| Boolean2 Boolean
-- note the underscore at the end for the `rep` parameter of class Generic
derive instance genericIntOrBoolean2 :: GR.Generic IntOrBoolean2 _
instance showIntOrBoolean2 :: Show IntOrBoolean2 where
show = genericShow
-- now we get a Show based on Generic
instance readForeignIntOrBoolean2 :: JSON.ReadForeign IntOrBoolean2 where
readImpl f = GR.to <$> untaggedSumRep f
-- as noted above, mapping to so that we go from F rep to F IntOrBoolean
class UntaggedSumRep rep where
untaggedSumRep :: Foreign -> Foreign.F rep
So with our class UntaggedSumRep
, we have our method untaggedSumRep
for decoding Foreign
to rep
.
Once we have this code, we’ll get some errors about missing instances for Sum
, Constructor
, and Argument
as expected.
First, we define our Sum
instance so we take the alternative of a Inl
construction and Inr
construction:
instance untaggedSumRepSum ::
( UntaggedSumRep a
, UntaggedSumRep b
) => UntaggedSumRep (GR.Sum a b) where
untaggedSumRep f
= GR.Inl <$> untaggedSumRep f
<|> GR.Inr <$> untaggedSumRep f
And in our instance we have clearly constrained a
and b
for having instances of UntaggedSumRep
, so that we can use untaggedSumRep
on the members.
Then we define our Constructor
instance:
instance untaggedSumRepConstructor ::
( UntaggedSumRep a
) => UntaggedSumRep (GR.Constructor name a) where
untaggedSumRep f = GR.Constructor <$> untaggedSumRep f
This definition similar to above, but just with our single constructor case.
This is where you would try reading f
into a record by doing something like record :: { tag :: String, value :: Foreign } <- f
in a do block, if you wanted to represent sum types in that way. Sky’s the limit!
Then let’s define the argument instance that will call readImpl
on the Foreign
value.
instance untaggedSumRepArgument ::
( JSON.ReadForeign a
) => UntaggedSumRep (GR.Argument a) where
untaggedSumRep f = GR.Argument <$> JSON.readImpl f
And so at this level, we try to decode the Foreign
value directly to the type of the argument.
With just these few lines of code, we now have generic decoding for our untagged sum type encoding that we can apply to any sum type where Generic
is derived and the generic representation contains Sum
, Constructor
, and Argument
. To get started with your own instances, check out the example in test/Generic.purs in the Simple-JSON repo.
Working with “Enum” sum types¶
If you have sum types where all of the constructors are nullary, you may want to work with them as string literals. For example:
data Fruit
= Abogado
| Boat
| Candy
derive instance genericFruit :: Generic Fruit _
Like the above, we should write a function that can work with the generic representation of sum types, so that we can apply this to all enum-like sum types that derive Generic
and use it like so:
instance fruitReadForeign :: JSON.ReadForeign Fruit where
readImpl = enumReadForeign
enumReadForeign :: forall a rep
. Generic a rep
=> EnumReadForeign rep
=> Foreign
-> Foreign.F a
enumReadForeign f =
to <$> enumReadForeignImpl f
First, we define our class which is take the rep and return a Foreign.F rep
:
class EnumReadForeign rep where
enumReadForeignImpl :: Foreign -> Foreign.F rep
Then we only need two instance for this class. First, the instance for the Sum
type to split cases:
instance sumEnumReadForeign ::
( EnumReadForeign a
, EnumReadForeign b
) => EnumReadForeign (Sum a b) where
enumReadForeignImpl f
= Inl <$> enumReadForeignImpl f
<|> Inr <$> enumReadForeignImpl f
Then we need to match on Constructor
, but only when its second argument is NoArguments
, as we want only to work with enum sum types.
instance constructorEnumReadForeign ::
( IsSymbol name
) => EnumReadForeign (Constructor name NoArguments) where
enumReadForeignImpl f = do
s <- JSON.readImpl f
if s == name
then pure $ Constructor NoArguments
else throwError <<< pure <<< Foreign.ForeignError $
"Enum string " <> s <> " did not match expected string " <> name
where
name = reflectSymbol (SProxy :: SProxy name)
We put a IsSymbol
constraint on name
so that can reflect it to a string and check if it is equal to the string that is taken from the foreign value. In the success branch, we construct the Constructor
value with the NoArguments
value.
With just this, we can now decode all enum-like sums:
readFruit :: String -> Either Foreign.MultipleErrors Fruit
readFruit = JSON.readJSON
main = do
logShow $ readFruit "\"Abogado\""
logShow $ readFruit "\"Boat\""
logShow $ readFruit "\"Candy\""