Converting Data To and From Json in PureScript

Last updated August 4th, 2017 for PureScript 0.11.x

In this post I will show you how to manually convert PureScript data into JSON and then decode back into PureScript data. The technical name for this is marshalling. To learn how to automatically derive encoding and decoding code, see Automatically de/encoding JSON in Purescript using Generics-Rep.

Let's start with data types representing the board game Quarto and a type for transferring game state.

data Property
  = Hollow
  | Solid
  | Tall
  | Short
  | Dark
  | Light
  | Square
  | Circle

type PieceID = String
type PositionID = String
type Piece = StrMap Property
type Board = StrMap (Maybe Piece)

type GameState = {
  ondeck :: Maybe PieceID,
  board :: Board
}

data Protocol = SendState GameState

The functions I'll be using are located in the following modules.

import Data.Argonaut (
  class EncodeJson,
  class DecodeJson,
  jsonSingletonObject, decodeJson, encodeJson, getField,
  fromString, toString)
import Data.Argonaut.Core (jsonEmptyObject, stringify)
import Data.Argonaut.Parser (jsonParser)
import Data.Argonaut.Encode.Combinators ((:=), (~>), assoc, extend)

Let's start with encoding/decoding Property to and from a string.

instance encodeProperty :: EncodeJson Property where
  encodeJson r = fromString $ case r of
    Hollow -> "hollow"
    Solid -> "solid"
    Tall -> "tall"
    Short -> "short"
    Dark -> "dark"
    Light -> "light"
    Square -> "square"
    Circle -> "circle"

instance decodeProperty :: DecodeJson Property where
  decodeJson s = do
    a <- maybeFail "Could not read Property string." $ toString s
    case a of
      "hollow" -> Right Hollow
      "solid" -> Right Solid
      "tall" -> Right Tall
      "short" -> Right Short
      "dark" -> Right Dark
      "light" -> Right Light
      "square" -> Right Square
      "circle" -> Right Circle
      _ -> Left $ "Property string was not one of the" <>
                  "expected values. Value was '" <> a <> "'"

Note that maybeFail is a utility function I use to explicitly set the error message if Argonaut fails with a Nothing. This is really helpful when tracking down why encoding/decoding isn't working.

maybeFail :: forall a. String -> Maybe a -> Either String a
maybeFail s m = case m of
  Just mm -> Right mm
  Nothing -> Left s

The encode/decode instances for Property are all we need in order to encode/decode Piece and Board, since Argonaut provides EncodeJson and DecodeJson instances for StrMap and Maybe. So all that's left is to create instances for the Protocol type.

-- note (:=) is the same as `assoc`
-- and (~>) is the same as `extend`
instance encodeProtocol :: EncodeJson Protocol where
  encodeJson (SendState gs) = jsonSingletonObject "state"
    ("ondeck" := gs.ondeck
      ~> "board" := gs.board
      ~> jsonEmptyObject
      )

instance decodeProtocol :: DecodeJson Protocol where
  decodeJson json = do
   obj <- decodeJson json
   state <- getField obj "state"
   board <- getField state "board"
   ondeck <- getField state "ondeck"
   pure $ SendState { ondeck, board }

This will allows us to convert Protocol into the json object { state : { board : {}, ondeck : {}}}.

Finally, to illustrate how you would use these instances (or rather how they're used behind the scenes) these two functions encode/decode the Protocol to and from text. These would be used with your API code for marshalling server requests and responses.

protocolToString :: Protocol -> String
protocolToString = stringify <<< encodeJson

stringToProtocol :: String -> Either String Protocol
stringToProtocol string = do
  ss <- jsonParser string
  decodeJson ss

For PureScript 0.10.x

Note: Below this point is content from the previous version of this post. Code examples are for PureScript 0.10.x ecosystem but the ideas are still applicable.

purescript-argonaut-codecs provides type classes for both encoding and decoding to JSON. If you look at the code for DecodeJson, you'll see there are already an type class instance to convert a StrMap a and a Maybe a into an a.

class DecodeJson a where
  decodeJson :: Json -> Either String a

instance decodeStrMap :: DecodeJson a => DecodeJson (StrMap a)

instance decodeJsonMaybe :: DecodeJson a => DecodeJson (Maybe a)

Since Board is an StrMap of Maybe Piece, and Piece is an StrMap of Property, all we to do is create an instance of DecodeJson for Property, and we'll have a way to automatically decode both a Piece and a Board. Similarly, EncodeJson provided instances for StrMap and Maybe, so we just needed to create an instance of EncodeJson for Property.

class EncodeJson a where
  encodeJson :: a -> Json

instance encodeStrMap :: EncodeJson a => EncodeJson (StrMap a)
instance encodeJsonMaybe :: EncodeJson a => EncodeJson (Maybe a)
instance encodeJsonString :: EncodeJson String

Since purescript-argonaut-codecs provides functions to generically derive instances of EncodeJson and DecodeJson (gEncodeJson and gDecodeJson), we just need to derive a Property instance for Generic.

derive instance gProperty  :: Generic Property
instance encodeProperty :: EncodeJson Property where encodeJson = gEncodeJson
instance decodeProperty :: DecodeJson Property where decodeJson = gDecodeJson

So now when we want to convert the data, we can call encodeJson board, encodeJson piece, or encodeJson property to convert those data types into a Json type. Then we can convert the Json type into a string using stringify.