Package

purescript-option

Repository
joneshf/purescript-option
License
MIT
Uploaded by
joneshf
Published on
2019-08-08

A data type for optional values.

Table of Contents

Explanation: Motivation for Option _

There are a few different data types that encapsulate ideas in programming.

Records capture the idea of a collection of key/value pairs where every key and value exist. E.g. Record (foo :: Boolean, bar :: Int) means that both foo and bar exist and with values all of the time.

Variants capture the idea of a collection of key/value pairs where exactly one of the key/value pairs exist. E.g. Variant (foo :: Boolean, bar :: Int) means that either only foo exists with a value or only bar exists with a value, but not both at the same time.

Options capture the idea of a collection of key/value pairs where any key and value may or may not exist. E.g. Option (foo :: Boolean, bar :: Int) means that either only foo exists with a value, only bar exists with a value, both foo and bar exist with values, or neither foo nor bar exist.

The distinction between these data types means that we can describe problems more accurately.

How To: Make a function with optional values

Let's say we want to make a greeting function where people can pass in an Option ( name :: String, title :: String ) to override the default behavior. I.e. we want something like: greeting :: Option.Option ( name :: String, title :: String ) -> String. The implementation should be fairly straight forward:

greeting :: Option.Option ( name :: String, title :: String ) -> String
greeting option = "Hello, " <> title' <> name'
  where
  name' :: String
  name' = case Option.get (Data.Symbol.SProxy :: _ "name") option of
    Data.Maybe.Just name -> name
    Data.Maybe.Nothing -> "World"

  title' :: String
  title' = case Option.get (Data.Symbol.SProxy :: _ "title") option of
    Data.Maybe.Just title -> title <> " "
    Data.Maybe.Nothing -> ""

We look up each key in the given Option _, and decide what to do with it. In the case of the "title", we append a space so the output is still legible. With the greeting function, we can pass in an option and alter the behavior:

> greeting (Option.fromRecord {})
"Hello, World"

> greeting (Option.fromRecord { title: "wonderful" })
"Hello, wonderful World"

> greeting (Option.fromRecord { name: "Pat" })
"Hello, Pat"

> greeting (Option.fromRecord { name: "Pat", title: "Dr." })
"Hello, Dr. Pat"

We've allowed people to override the behavior of the function with optional values!

It might be instructive to compare how we might write a similar function using a Record _ instead of Option _:

greeting' ::
  Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) ->
  String
greeting' option = "Hello, " <> title' <> name'
  where
  name' :: String
  name' = case option.name of
    Data.Maybe.Just name -> name
    Data.Maybe.Nothing -> "World"

  title' :: String
  title' = case option.title of
    Data.Maybe.Just title -> title <> " "
    Data.Maybe.Nothing -> ""

To implement greeting', nothing really changed. We used the built-in dot operator to fetch the keys out of the record, but we could have just as easily used Record.get (which would have highlighted the similarlities even more).

To use greeting', we force the users of greeting' to do always give us a value in the record:

> User.greeting' { name: Data.Maybe.Nothing, title: Data.Maybe.Nothing }
"Hello, World"

> User.greeting' { name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful" }
"Hello, wonderful World"

> User.greeting' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing }
"Hello, Pat"

> User.greeting' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr." }
"Hello, Dr. Pat"

How To: Make a function with optional values from a record

Let's say we want to solve a similar problem as before, but we don't want to force people to create the Option _ themselves. We want to allow people to pass in a record that may be missing some fields. To write this version of greeting, we'll need to use the FromRecord typeclass (see the section on FromRecord for more information). I.e. we want something like: greeting :: forall record. Option.FromRecord record ( name :: String, title :: String ) => Record record -> String.

The implementation moves the work of constructing the Option _, but should also be straight forward:

greeting ::
  forall record.
  Option.FromRecord record ( name :: String, title :: String ) =>
  Record record ->
  String
greeting record = "Hello, " <> title' <> name'
  where
  name' :: String
  name' = case Option.get (Data.Symbol.SProxy :: _ "name") option of
    Data.Maybe.Just name -> name
    Data.Maybe.Nothing -> "World"

  option :: Option.Option ( name :: String, title :: String )
  option = Option.fromRecord record

  title' :: String
  title' = case Option.get (Data.Symbol.SProxy :: _ "title") option of
    Data.Maybe.Just title -> title <> " "
    Data.Maybe.Nothing -> ""

We can use this similar to how we used the previous implementation of greeting. Instead of needing to construct an Option _ and pass it in, we give the Record _ directly:

> greeting {}
"Hello, World"

> greeting { title: "wonderful" }
"Hello, wonderful World"

> greeting { name: "Pat" }
"Hello, Pat"

> greeting { name: "Pat", title: "Dr." }
"Hello, Dr. Pat"

We've allowed people to override the behavior of the function with optional values using a record!

How To: Decode and Encode JSON with optional values in purescript-argonaut

A common pattern with JSON objects is that keys do not always have to be present. Some APIs make the distinction between a JSON object like { "name": "Pat" } and one like { "name": "Pat", "title": null }. In the first case, it might recognize that the "title" key does not exist, and behave in a different way from the "title" key having a value of null. In the second case, it might notice that the "title" key exists and work with the value assuming it's good to go; the null might eventually cause a failure later.

In many cases, what we want is to not generate any fields that do not exist. Using purescript-argonaut, Option _ can help with that idea:

decode ::
  Data.Argonaut.Core.Json ->
  Data.Either.Either String (Option.Option ( name :: String, title :: String ))
decode = Data.Argonaut.Decode.Class.decodeJson

encode ::
  Option.Option ( name :: String, title :: String ) ->
  Data.Argonaut.Core.Json
encode = Data.Argonaut.Encode.Class.encodeJson

We can give that a spin with some different JSON values:

> decode =<< Data.Argonaut.Parser.jsonParser """{}"""
(Right (Option.fromRecord {}))

> decode =<< Data.Argonaut.Parser.jsonParser """{"title": "wonderful"}"""
(Right (Option.fromRecord { title: "wonderful" }))

> decode =<< Data.Argonaut.Parser.jsonParser """{"name": "Pat"}"""
(Right (Option.fromRecord { name: "Pat" }))

> decode =<< Data.Argonaut.Parser.jsonParser """{"name": "Pat", "title": "Dr."}"""
(Right (Option.fromRecord { name: "Pat", title: "Dr." }))

We can also produce some different JSON values:

> Data.Argonaut.Core.stringify (encode (Option.fromRecord {}))
"{}"

> Data.Argonaut.Core.stringify (encode (Option.fromRecord { title: "wonderful" }))
"{\"title\":\"wonderful\"}"

> Data.Argonaut.Core.stringify (encode (Option.fromRecord { name: "Pat" }))
"{\"name\":\"Pat\"}"

> Data.Argonaut.Core.stringify (encode (Option.fromRecord { name: "Pat", title: "Dr." }))
"{\"title\":\"Dr.\",\"name\":\"Pat\"}"

Notice that we don't end up with a "title" field in the JSON output unless we have a title field in our record.

It might be instructive to compare how we might write a similar functions using a Record _ instead of Option _: With purescript-argonaut, the instances for decoding and encoding on records expect the field to always exist no matter its value. If we attempt to go directly to Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ):

decode' ::
  Data.Argonaut.Core.Json ->
  Data.Either.Either String (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
decode' = Data.Argonaut.Decode.Class.decodeJson

encode' ::
  Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) ->
  Data.Argonaut.Core.Json
encode' = Data.Argonaut.Encode.Class.encodeJson

We won't get the behavior we expect:

> decode' =<< Data.Argonaut.Parser.jsonParser """{}"""
(Left "JSON was missing expected field: title")

> decode' =<< Data.Argonaut.Parser.jsonParser """{"title": "wonderful"}"""
(Left "JSON was missing expected field: name")

> decode' =<< Data.Argonaut.Parser.jsonParser """{"name": "Pat"}"""
(Left "JSON was missing expected field: title")

> decode' =<< Data.Argonaut.Parser.jsonParser """{"name": "Pat", "title": "Dr."}"""
(Right { name: (Just "Pat"), title: (Just "Dr.") })

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Nothing, title: Data.Maybe.Nothing })
"{\"title\":null,\"name\":null}"

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful" })
"{\"title\":\"wonderful\",\"name\":null}"

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing })
"{\"title\":null,\"name\":\"Pat\"}"

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr." })
"{\"title\":\"Dr.\",\"name\":\"Pat\"}"

Unless both fields exist, we cannot decode the JSON object. Similarly, no matter what the values are, we always encode them into a JSON object.

In order to emulate the behavior of an optional field, we have to name the record, and write our own instances:

newtype Greeting
  = Greeting
  ( Record
      ( name :: Data.Maybe.Maybe String
      , title :: Data.Maybe.Maybe String
      )
  )

derive instance genericGreeting :: Data.Generic.Rep.Generic Greeting _

instance showGreeting :: Show Greeting where
  show = Data.Generic.Rep.Show.genericShow

instance decodeJsonGreeting :: Data.Argonaut.Decode.Class.DecodeJson Greeting where
  decodeJson json = do
    object <- Data.Argonaut.Decode.Class.decodeJson json
    name <- Data.Argonaut.Decode.Combinators.getFieldOptional object "name"
    title <- Data.Argonaut.Decode.Combinators.getFieldOptional object "title"
    pure (Greeting { name, title })

instance encodeJsonGreeting :: Data.Argonaut.Encode.Class.EncodeJson Greeting where
  encodeJson (Greeting { name, title }) =
    Data.Argonaut.Encode.Combinators.extendOptional
      (Data.Argonaut.Encode.Combinators.assocOptional "name" name)
      ( Data.Argonaut.Encode.Combinators.extendOptional
          (Data.Argonaut.Encode.Combinators.assocOptional "title" title)
          (Data.Argonaut.Core.jsonEmptyObject)
      )

If we try decoding and encoding now, we get something closer to what we wanted:

> Data.Argonaut.Decode.Class.decodeJson =<< Data.Argonaut.Parser.jsonParser """{}""" :: Data.Either.Either String Greeting
(Right (Greeting { name: Nothing, title: Nothing }))

> Data.Argonaut.Decode.Class.decodeJson =<< Data.Argonaut.Parser.jsonParser """{"title": "wonderful"}""" :: Data.Either.Either String Greeting
(Right (Greeting { name: Nothing, title: (Just "wonderful") }))

> Data.Argonaut.Decode.Class.decodeJson =<< Data.Argonaut.Parser.jsonParser """{"name": "Pat"}""" :: Data.Either.Either String Greeting
(Right (Greeting { name: (Just "Pat"), title: Nothing }))

> Data.Argonaut.Decode.Class.decodeJson =<< Data.Argonaut.Parser.jsonParser """{"name": "Pat", "title": "Dr."}""" :: Data.Either.Either String Greeting
(Right (Greeting { name: (Just "Pat"), title: (Just "Dr.") }))

> Data.Argonaut.Core.stringify (Data.Argonaut.Encode.Class.encodeJson (Greeting { name: Data.Maybe.Nothing, title: Data.Maybe.Nothing }))
"{}"

> Data.Argonaut.Core.stringify (Data.Argonaut.Encode.Class.encodeJson (Greeting { name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful" }))
"{\"title\":\"wonderful\"}"

> Data.Argonaut.Core.stringify (Data.Argonaut.Encode.Class.encodeJson (Greeting { name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing }))
"{\"name\":\"Pat\"}"

> Data.Argonaut.Core.stringify (Data.Argonaut.Encode.Class.encodeJson (Greeting { name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr." }))
"{\"title\":\"Dr.\",\"name\":\"Pat\"}"

How To: Decode and Encode JSON with optional values in purescript-codec-argonaut

A common pattern with JSON objects is that keys do not always have to be present. Some APIs make the distinction between a JSON object like { "name": "Pat" } and one like { "name": "Pat", "title": null }. In the first case, it might recognize that the "title" key does not exist, and behave in a different way from the "title" key having a value of null. In the second case, it might notice that the "title" key exists and work with the value assuming it's good to go; the null might eventually cause a failure later.

In many cases, what we want is to not generate any fields that do not exist. Using purescript-codec-argonaut, Option _ can help with that idea:

jsonCodec :: Data.Codec.Argonaut.JsonCodec (Option.Option ( name :: String, title :: String ))
jsonCodec =
  Option.jsonCodec
    { name: Data.Codec.Argonaut.string
    , title: Data.Codec.Argonaut.string
    }

We can add a couple of helpers to make decoding/encoding easier in the REPL:

decode ::
  Data.Argonaut.Core.Json ->
  Data.Either.Either Data.Codec.Argonaut.JsonDecodeError (Option.Option ( name :: String, title :: String ))
decode = Data.Codec.Argonaut.decode jsonCodec

encode ::
  Option.Option ( name :: String, title :: String ) ->
  Data.Argonaut.Core.Json
encode = Data.Codec.Argonaut.encode jsonCodec

parse ::
  String ->
  Data.Either.Either String (Option.Option ( name :: String, title :: String ))
parse string = do
  json <- Data.Argonaut.Parser.jsonParser string
  case decode json of
    Data.Either.Left err -> Data.Either.Left (Data.Codec.Argonaut.printJsonDecodeError err)
    Data.Either.Right option -> Data.Either.Right option

We can give that a spin with some different JSON values:

> parse """{}"""
(Right (Option.fromRecord {}))

> parse """{"title": "wonderful"}"""
(Right (Option.fromRecord { title: "wonderful" }))

> parse """{"name": "Pat"}"""
(Right (Option.fromRecord { name: "Pat" }))

> parse """{"name": "Pat", "title": "Dr."}"""
(Right (Option.fromRecord { name: "Pat", title: "Dr." }))

We can also produce some different JSON values:

> Data.Argonaut.Core.stringify (encode (Option.fromRecord {}))
"{}"

> Data.Argonaut.Core.stringify (encode (Option.fromRecord { title: "wonderful" }))
"{\"title\":\"wonderful\"}"

> Data.Argonaut.Core.stringify (encode (Option.fromRecord { name: "Pat" }))
"{\"name\":\"Pat\"}"

> Data.Argonaut.Core.stringify (encode (Option.fromRecord { name: "Pat", title: "Dr." }))
"{\"name\":\"Pat\",\"title\":\"Dr.\"}"

Notice that we don't end up with a "title" field in the JSON output unless we have a title field in our record.

It might be instructive to compare how we might write a similar functions using a Record _ instead of Option _: With purescript-codec-argonaut, there are a couple of codecs that ship with the package for records: Data.Codec.Argonaut.recordProp and Data.Codec.Argonaut.Record.record. Each of those codecs expect the field to always exist no matter its value. The difference between those codecs is not very relevant except to say that the latter requires less characters to use. There are also a couple of codecs that ship with the package for Data.Maybe.Maybe _: Data.Codec.Argonaut.Common.maybe and Data.Codec.Argonaut.Compat.maybe. The former decodes/encodes with tagged values, the latter with nulls. If we attempt to go directly to Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) and Data.Codec.Argonaut.Common.maybe:

decode' ::
  Data.Argonaut.Core.Json ->
  Data.Either.Either Data.Codec.Argonaut.JsonDecodeError (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
decode' = Data.Codec.Argonaut.decode jsonCodec'

encode' ::
  Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) ->
  Data.Argonaut.Core.Json
encode' = Data.Codec.Argonaut.encode jsonCodec'

jsonCodec' :: Data.Codec.Argonaut.JsonCodec (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
jsonCodec' =
  Data.Codec.Argonaut.Record.object
    "Greeting"
    { name: Data.Codec.Argonaut.Common.maybe Data.Codec.Argonaut.string
    , title: Data.Codec.Argonaut.Common.maybe Data.Codec.Argonaut.string
    }

parse' ::
  String ->
  Data.Either.Either String (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
parse' string = do
  json <- Data.Argonaut.Parser.jsonParser string
  case decode' json of
    Data.Either.Left err -> Data.Either.Left (Data.Codec.Argonaut.printJsonDecodeError err)
    Data.Either.Right option -> Data.Either.Right option

We won't get the behavior we expect:

> parse' """{}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key title:\n  No value was found.")

> parse' """{"title": "wonderful"}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key title:\n  Under 'Maybe':\n  Expected value of type 'Object'.")

> parse' """{"name": "Pat"}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key title:\n  No value was found.")

> parse' """{"name": "Pat", "title": "Dr."}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key title:\n  Under 'Maybe':\n  Expected value of type 'Object'.")

> parse' """{"name": {"tag": "Just", "value": "Pat"}, "title": {"tag": "Just", "value": "Dr."}}"""
(Right { name: (Just "Pat"), title: (Just "Dr.") })

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Nothing, title: Data.Maybe.Nothing })
"{\"name\":{\"tag\":\"Nothing\"},\"title\":{\"tag\":\"Nothing\"}}"

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful" })
"{\"name\":{\"tag\":\"Nothing\"},\"title\":{\"tag\":\"Just\",\"value\":\"wonderful\"}}"

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing })
"{\"name\":{\"tag\":\"Just\",\"value\":\"Pat\"},\"title\":{\"tag\":\"Nothing\"}}"

> Data.Argonaut.Core.stringify (encode' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr." })
"{\"name\":{\"tag\":\"Just\",\"value\":\"Pat\"},\"title\":{\"tag\":\"Just\",\"value\":\"Dr.\"}}"

Unless both fields exist, we cannot decode the JSON object. Not only is every field required, they're serialized as tagged values. Similarly, no matter what the values are, we always encode them into a JSON object.

If we try with Data.Codec.Argonaut.Compat.maybe:

decode'' ::
  Data.Argonaut.Core.Json ->
  Data.Either.Either Data.Codec.Argonaut.JsonDecodeError (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
decode'' = Data.Codec.Argonaut.decode jsonCodec''

encode'' ::
  Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) ->
  Data.Argonaut.Core.Json
encode'' = Data.Codec.Argonaut.encode jsonCodec''

jsonCodec'' :: Data.Codec.Argonaut.JsonCodec (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
jsonCodec'' =
  Data.Codec.Argonaut.Record.object
    "Greeting"
    { name: Data.Codec.Argonaut.Compat.maybe Data.Codec.Argonaut.string
    , title: Data.Codec.Argonaut.Compat.maybe Data.Codec.Argonaut.string
    }

parse'' ::
  String ->
  Data.Either.Either String (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
parse'' string = do
  json <- Data.Argonaut.Parser.jsonParser string
  case decode'' json of
    Data.Either.Left err -> Data.Either.Left (Data.Codec.Argonaut.printJsonDecodeError err)
    Data.Either.Right option -> Data.Either.Right option

We also don't get the behavior we expect:

> parse'' """{}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key title:\n  No value was found.")

> parse'' """{"title": "wonderful"}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key name:\n  No value was found.")

> parse'' """{"name": "Pat"}"""
(Left "An error occurred while decoding a JSON value:\n  Under 'Greeting':\n  At object key title:\n  No value was found.")

> parse'' """{"name": "Pat", "title": "Dr."}"""
(Right { name: (Just "Pat"), title: (Just "Dr.") })

> Data.Argonaut.Core.stringify (encode'' { name: Data.Maybe.Nothing, title: Data.Maybe.Nothing })
"{\"name\":null,\"title\":null}"

> Data.Argonaut.Core.stringify (encode'' { name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful" })
"{\"name\":null,\"title\":\"wonderful\"}"

> Data.Argonaut.Core.stringify (encode'' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing })
"{\"name\":\"Pat\",\"title\":null}"

> Data.Argonaut.Core.stringify (encode'' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr." })
"{\"name\":\"Pat\",\"title\":\"Dr.\"}"

Unless both fields exist, we cannot decode the JSON object. Similarly, no matter what the values are, we always encode them into a JSON object.

In order to emulate the behavior of an optional field, we have to use a different codec:

optionalField ::
  forall label record record' value.
  Data.Symbol.IsSymbol label =>
  Prim.Row.Cons label (Data.Maybe.Maybe value) record' record =>
  Prim.Row.Lacks label record' =>
  Data.Symbol.SProxy label ->
  Data.Codec.Argonaut.JsonCodec value ->
  Data.Codec.Argonaut.JPropCodec (Record record') ->
  Data.Codec.Argonaut.JPropCodec (Record record)
optionalField label codecValue codecRecord =
  Data.Codec.GCodec
    (Control.Monad.Reader.Trans.ReaderT decodeField)
    (Data.Profunctor.Star.Star encodeField)
  where
  decodeField ::
    Foreign.Object.Object Data.Argonaut.Core.Json ->
    Data.Either.Either Data.Codec.Argonaut.JsonDecodeError (Record record)
  decodeField object' = do
    record <- Data.Codec.Argonaut.decode codecRecord object'
    case Foreign.Object.lookup key object' of
      Data.Maybe.Just json -> case Data.Codec.Argonaut.decode codecValue json of
        Data.Either.Left error -> Data.Either.Left (Data.Codec.Argonaut.AtKey key error)
        Data.Either.Right value -> Data.Either.Right (Record.insert label (Data.Maybe.Just value) record)
      Data.Maybe.Nothing -> Data.Either.Right (Record.insert label Data.Maybe.Nothing record)

  encodeField ::
    Record record ->
    Control.Monad.Writer.Writer (Data.List.List (Data.Tuple.Tuple String Data.Argonaut.Core.Json)) (Record record)
  encodeField record = do
    case Record.get label record of
      Data.Maybe.Just value ->
        Control.Monad.Writer.Class.tell
          ( Data.List.Cons
              (Data.Tuple.Tuple key (Data.Codec.Argonaut.encode codecValue value))
              Data.List.Nil
          )
      Data.Maybe.Nothing -> pure unit
    Control.Monad.Writer.Class.tell
      (Data.Codec.Argonaut.encode codecRecord (Record.delete label record))
    pure record

  key :: String
  key = Data.Symbol.reflectSymbol label

With this codec defined, we can implement a codec for the record with optional fields:

decode''' ::
  Data.Argonaut.Core.Json ->
  Data.Either.Either Data.Codec.Argonaut.JsonDecodeError (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
decode''' = Data.Codec.Argonaut.decode jsonCodec'''

encode''' ::
  Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) ->
  Data.Argonaut.Core.Json
encode''' = Data.Codec.Argonaut.encode jsonCodec'''

jsonCodec''' :: Data.Codec.Argonaut.JsonCodec (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
jsonCodec''' =
  Data.Codec.Argonaut.object
    "Greeting"
    ( optionalField (Data.Symbol.SProxy :: _ "name") Data.Codec.Argonaut.string
        $ optionalField (Data.Symbol.SProxy :: _ "title") Data.Codec.Argonaut.string
        $ Data.Codec.Argonaut.record
    )

parse''' ::
  String ->
  Data.Either.Either String (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
parse''' string = do
  json <- Data.Argonaut.Parser.jsonParser string
  case decode''' json of
    Data.Either.Left err -> Data.Either.Left (Data.Codec.Argonaut.printJsonDecodeError err)
    Data.Either.Right option -> Data.Either.Right option

If we try decoding and encoding now, we get something closer to what we wanted:

> parse''' """{}"""
(Right { name: Nothing, title: Nothing })

> parse''' """{"title": "wonderful"}"""
(Right { name: Nothing, title: (Just "wonderful") })

> parse''' """{"name": "Pat"}"""
(Right { name: (Just "Pat"), title: Nothing })

> parse''' """{"name": "Pat", "title": "wonderful"}"""
(Right { name: (Just "Pat"), title: (Just "wonderful") })

> Data.Argonaut.Core.stringify (encode''' { name: Data.Maybe.Nothing, title: Data.Maybe.Nothing })
"{}"

> Data.Argonaut.Core.stringify (encode''' { name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful" })
"{\"title\":\"wonderful\"}"

> Data.Argonaut.Core.stringify (encode''' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing })
"{\"name\":\"Pat\"}"

> Data.Argonaut.Core.stringify (encode''' { name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr." })
"{\"name\":\"Pat\",\"title\":\"Dr.\"}"

How To: Decode and Encode JSON with optional values in purescript-simple-json

A common pattern with JSON objects is that keys do not always have to be present. Some APIs make the distinction between a JSON object like { "name": "Pat" } and one like { "name": "Pat", "title": null }. In the first case, it might recognize that the "title" key does not exist, and behave in a different way from the "title" key having a value of null. In the second case, it might notice that the "title" key exists and work with the value assuming it's good to go; the null might eventually cause a failure later.

In many cases, what we want is to not generate any fields that do not exist. Using purescript-simple-json, Option _ can help with that idea:

readJSON ::
  String ->
  Simple.JSON.E (Option.Option ( name :: String, title :: String ))
readJSON = Simple.JSON.readJSON

writeJSON ::
  Option.Option ( name :: String, title :: String ) ->
  String
writeJSON = Simple.JSON.writeJSON

We can give that a spin with some different JSON values:

> readJSON """{}"""
(Right (Option.fromRecord {}))

> readJSON """{"title": "wonderful"}"""
(Right (Option.fromRecord { title: "wonderful" }))

> readJSON """{"name": "Pat"}"""
(Right (Option.fromRecord { name: "Pat" }))

> readJSON """{"name": "Pat", "title": "Dr."}"""
(Right (Option.fromRecord { name: "Pat", title: "Dr." }))

We can also produce some different JSON values:

> writeJSON (Option.fromRecord {})
"{}"

> writeJSON (Option.fromRecord {title: "wonderful"})
"{\"title\":\"wonderful\"}"

> writeJSON (Option.fromRecord {name: "Pat"})
"{\"name\":\"Pat\"}"

> writeJSON (Option.fromRecord {name: "Pat", title: "Dr."})
"{\"title\":\"Dr.\",\"name\":\"Pat\"}"

Notice that we don't end up with a "title" field in the JSON output unless we have a title field in our record.

It might be instructive to compare how we might write a similar functions using a Record _ instead of Option _: With purescript-simple-json, the instances for decoding and encoding on records handle Data.Maybe.Maybe _ values like they are optional. If we attempt to go directly to Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ):

readJSON' ::
  String ->
  Simple.JSON.E (Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ))
readJSON' = Simple.JSON.readJSON

writeJSON' ::
  Record ( name :: Data.Maybe.Maybe String, title :: Data.Maybe.Maybe String ) ->
  String
writeJSON' = Simple.JSON.writeJSON

We get the behavior we expect:

> readJSON' """{}"""
(Right { name: Nothing, title: Nothing })

> readJSON' """{"title": "wonderful"}"""
(Right { name: Nothing, title: (Just "wonderful") })

> readJSON' """{"name": "Pat"}"""
(Right { name: (Just "Pat"), title: Nothing })

> readJSON' """{"name": "Pat", "title": "Dr."}"""
(Right { name: (Just "Pat"), title: (Just "Dr.") })
> decode' =<< Data.Argonaut.Parser.jsonParser """{}"""
(Left "JSON was missing expected field: title")

> writeJSON' {name: Data.Maybe.Nothing, title: Data.Maybe.Nothing}
"{}"

> writeJSON' {name: Data.Maybe.Nothing, title: Data.Maybe.Just "wonderful"}
"{\"title\":\"wonderful\"}"

> writeJSON' {name: Data.Maybe.Just "Pat", title: Data.Maybe.Nothing}
"{\"name\":\"Pat\"}"

> writeJSON' {name: Data.Maybe.Just "Pat", title: Data.Maybe.Just "Dr."}
"{\"title\":\"Dr.\",\"name\":\"Pat\"}"

How To: Provide an easier API for DateTime

The API for Data.DateTime is pretty nice because it means we cannot construct invalid dates. What's not so nice about it is that it pushes all of the correctness onto us. It might be a little easier to use if the API would allow optional values to be passed in and default to something sensible. For instance, constructing a Data.DateTime.DateTime can be done by passing in both a Data.Date.Date and a Data.Time.Time: The issue is, how do we construct a Data.Date.Date or Data.Time.Time.

One way to get construct these values is to use the Data.Enum.Enum instance for both of them:

> Data.DateTime.DateTime bottom bottom
(DateTime (Date (Year -271820) January (Day 1)) (Time (Hour 0) (Minute 0) (Second 0) (Millisecond 0)))

This gives us a value, but some of its parts might not be what we really want; e.g. the year is -271820.

If we wanted to alter the year, we have to use Data.Enum.toEnum to construct a different year, then Data.DateTime.modifyDate, and Data.Date.canonicalDate to thread the year through:

> Data.DateTime.modifyDate (\date -> Data.Date.canonicalDate (Data.Maybe.fromMaybe bottom (Data.Enum.toEnum 2019)) (Data.Date.month date) (Data.Date.day date)) (Data.DateTime.DateTime bottom bottom)
(DateTime (Date (Year 2019) January (Day 1)) (Time (Hour 0) (Minute 0) (Second 0) (Millisecond 0)))

Or create it with the correct year from the get-go:

> Data.DateTime.DateTime (Data.Date.canonicalDate (Data.Maybe.fromMaybe bottom (Data.Enum.toEnum 2019)) bottom bottom) bottom
(DateTime (Date (Year 2019) January (Day 1)) (Time (Hour 0) (Minute 0) (Second 0) (Millisecond 0)))

That's a non-trivial amount of work in order to alter the year. We can clean it up with a named function that takes in an Int for the year and does all the boilerplate (using Data.Enum.toEnumWithDefaults to handle bounds a bit better):

dateTimeFromYear :: Int -> Data.DateTime.DateTime
dateTimeFromYear year =
  Data.DateTime.DateTime
    ( Data.Date.canonicalDate
        (Data.Enum.toEnumWithDefaults bottom top year)
        bottom
        bottom
    )
    bottom

This works decently for the year alone.

> dateTimeFromYear 2019
(DateTime (Date (Year 2019) January (Day 1)) (Time (Hour 0) (Minute 0) (Second 0) (Millisecond 0)))

Once we decide we are okay with the year, but want to alter the day instead, or that we want to alter both at the same time it becomes just as hard as if we hadn't done anything. We either need to implement something similar for each part or change the arguments to Data.Maybe.Maybe Ints.

An alternative is to use an option for each part of the Data.DateTime.DateTime:

type Option
  = ( day :: Int
    , hour :: Int
    , millisecond :: Int
    , minute :: Int
    , month :: Data.Date.Component.Month
    , second :: Int
    , year :: Int
    )

Then we can build a Data.DateTime.DateTime from whatever happens to be passed in:

dateTime ::
  forall record.
  Option.FromRecord record Option =>
  Record record ->
  Data.DateTime.DateTime
dateTime record = Data.DateTime.DateTime date time
  where
  date :: Data.Date.Date
  date = Data.Date.canonicalDate year month day
    where
    day :: Data.Date.Component.Day
    day = get (Data.Symbol.SProxy :: _ "day")

    month :: Data.Date.Component.Month
    month = Option.getWithDefault bottom (Data.Symbol.SProxy :: _ "month") options

    year :: Data.Date.Component.Year
    year = get (Data.Symbol.SProxy :: _ "year")

  get ::
    forall label proxy record' value.
    Data.Enum.BoundedEnum value =>
    Data.Symbol.IsSymbol label =>
    Prim.Row.Cons label Int record' Option =>
    proxy label ->
    value
  get proxy = case Option.get proxy options of
    Data.Maybe.Just x -> Data.Enum.toEnumWithDefaults bottom top x
    Data.Maybe.Nothing -> bottom

  options :: Option.Option Option
  options = Option.fromRecord record

  time :: Data.Time.Time
  time = Data.Time.Time hour minute second millisecond
    where
    hour :: Data.Time.Component.Hour
    hour = get (Data.Symbol.SProxy :: _ "hour")

    minute :: Data.Time.Component.Minute
    minute = get (Data.Symbol.SProxy :: _ "minute")

    millisecond :: Data.Time.Component.Millisecond
    millisecond = get (Data.Symbol.SProxy :: _ "millisecond")

    second :: Data.Time.Component.Second
    second = get (Data.Symbol.SProxy :: _ "second")

Now, we can construct a Data.DateTime.DateTime fairly easily:

> dateTime {}
(DateTime (Date (Year -271820) January (Day 1)) (Time (Hour 0) (Minute 0) (Second 0) (Millisecond 0)))

We can alter the year:

> dateTime {year: 2019}
(DateTime (Date (Year 2019) January (Day 1)) (Time (Hour 0) (Minute 0) (Second 0) (Millisecond 0)))

And, we can alter any of the components:

> dateTime {minute: 30, month: Data.Date.Component.April, year: 2019}
(DateTime (Date (Year 2019) April (Day 1)) (Time (Hour 0) (Minute 30) (Second 0) (Millisecond 0)))

Reference: FromRecord _ _

A typeclass for converting a Record _ into an Option _.

An instance FromRecord record option states that we can make an Option option from a Record record where every field present in the record is present in the option. E.g. FromRecord () ( name :: String ) says that the Option ( name :: String ) will have no value; and FromRecord ( name :: String ) ( name :: String ) says that the Option ( name :: String ) will have the given name value.

Since there is syntax for creating records, but no syntax for creating options, this typeclass can be useful for providing an easier to use interface to options.

E.g. Someone can say:

Option.fromRecord' { foo: true, bar: 31 }

Instead of having to say:

Option.insert
  (Data.Symbol.SProxy :: _ "foo")
  true
  ( Option.insert
      (Data.Symbol.SProxy :: _ "bar")
      31
      Option.empty
  )

Not only does it save a bunch of typing, it also mitigates the need for a direct dependency on SProxy _.

Modules
Option
Dependencies