Automatic JSON de/serialization with Purescript

3b48c91bf6b6f0bfd0fda50625598656?s=47 Justin Woo
February 03, 2017

Automatic JSON de/serialization with Purescript

Small talk about deriving Generics.Rep instances and doing automatic JSON de/serialization with Purescript

https://github.com/justinwoo/purescript-howto-foreign-generic

3b48c91bf6b6f0bfd0fda50625598656?s=128

Justin Woo

February 03, 2017
Tweet

Transcript

  1. Automatic JSON de/serialization with Purescript Justin Woo 3 Feb 17

  2. What do you mean by “de/serialization”? We should be able

    to take a given type and… • Convert values of the type to JSON (serialize) • Convert JSON to a result of values of the type (deserialize) What I don’t want • JSON.parse as String -> a ◦ Just pretending my arbitrary JS object is the correct type/shape is not fun ◦ undefined is not a function ◦ cannot read property ‘sanity’ of undefined ◦ etc.
  3. But we’ve been doing that for ages! If you’re not

    a JS programmer, then yes, you probably have been • Manually decoding JSON ◦ String -> Hashmap/Object/“Foreign” -> validate/extract properties -> construct object or throw IllegalArgumentException or Result<T> or what have you ▪ You really should not be doing this as it’s manual and error-prone • Annotation-based ◦ Java devs should be doing this with Jackson annotations and such ◦ “I do this with Joi/tcomb schemas” • “Generic”/“Shapeless”/type-level introspection-based ◦ Scala: JSON to case class or w/e, Haskell: Aeson, etc. ◦ Purescript with Foreign-Generic ◦ Rust json::decode?
  4. Brief primer Purescript-foreign uses IsForeign and AsForeign typeclass instances for

    de/serialization. Typeclasses in a nutshell: “it’s kind of like Interfaces” (cue booing) Currently two Generics approaches exist (might/probably will change by the time someone revisits these slides) 1. Generic (existing) 2. Generic.Rep (newer) a. enables a lot more powerful stuff b. like deriving Is/AsForeign instances for my cruddy JSON
  5. A simple record example Imagine we have a simple record

    (modeling JSON from a config file, HTTP request, etc.): newtype SimpleRecord = SimpleRecord { a :: Int , b :: String , c :: Boolean }
  6. Derive Generic.Rep Deriving a Generic.Rep instance is as easy as

    import Data.Generic.Rep as Rep derive instance repGenericSimpleRecord :: Rep.Generic SimpleRecord _ Now we can use any Rep.Generic a _ => [...] readGeneric :: forall a rep. (Generic a rep, GenericDecode rep) => Options -> Foreign -> F a toForeignGeneric :: forall a rep. (Generic a rep, GenericEncode rep) => Options -> a -> Foreign
  7. IsForeign/AsForeign specifics class IsForeign a where read :: Foreign ->

    F a Type with instance for IsForeign must have an implementation for read class AsForeign a where write :: a -> Foreign Same, but for write
  8. Writing instances accordingly instance isForeignSimpleRecord :: IsForeign SimpleRecord where read

    = readGeneric $ defaultOptions {unwrapSingleConstructors = true} instance asForeignSimpleRecord :: AsForeign SimpleRecord where write = toForeignGeneric $ defaultOptions {unwrapSingleConstructors = true} Options actually quite useful in some cases, as we’ll see later Only four lines of code to do this very explicitly!
  9. Reading JSON As simple as readJSON :: forall a. IsForeign

    a => String -> F a type F a = Except MultipleErrors a type MultipleErrors = NonEmptyList ForeignError Extracting the result: just run it! runExcept :: forall e a. Except e a -> Either e a
  10. Putting our instance to work testJSON original input expected =

    do log' "can be converted to JSON" (show original) json it "can be converted back" $ readJSON' json `shouldEqual` Right original it' "can be converted from JSON" input expected $ readJSON' input `shouldEqual` expected where readJSON' = runExcept <<< readJSON json = unsafeStringify <<< write $ original describe "SimpleRecord" do testJSON (SimpleRecord { a: 1, b: "b", c: true }) "{ \"a\": 123, \"b\": \"abc\", \"c\": false }" (Right (SimpleRecord { a: 123, b: “abc", c: false })) SimpleRecord ✓ can be converted to JSON (SimpleRecord { a: 1, b: "b", c: true }) -> {"c":true,"b":"b","a":1} ✓ can be converted back ✓ can be converted from JSON { "a": 123, "b": "abc", "c": false } -> (Right (SimpleRecord { a: 123, b: "abc", c: false }))
  11. But wait, there’s more! Things that should reasonably work easily:

    • Nested Records newtype NestedRecord = NestedRecord { d :: SimpleRecord } • Arrays and Null/Undefined values newtype RecordWithArrayAndNullOrUndefined = RecordWithArrayAndNullOrUndefined { intArray :: Array Int , optionalInt :: NullOrUndefined Int }
  12. More Results NestedRecord ✓ can be converted to JSON (NestedRecord

    { d: (SimpleRecord { a: 1, b: "b", c: true }) }) -> {"d":{"c":true,"b":"b","a":1}} ✓ can be converted back ✓ can be converted from JSON { "d": { "a": 123, "b": "abc", "c": false } } -> (Right (NestedRecord { d: (SimpleRecord { a: 123, b: "abc", c: false }) })) RecordWithArrayAndNullOrUndefined ✓ can be converted to JSON (RecordWithArrayAndNullOrUndefined { intArray: [1,2,3], optionalInt: (NullOrUndefined (Just 1)) }) -> {"optionalInt":1,"intArray":[1,2,3]} ✓ can be converted back ✓ can be converted from JSON { "intArray": [1, 2, 3] } -> (Right (RecordWithArrayAndNullOrUndefined { intArray: [1,2,3], optionalInt: (NullOrUndefined Nothing) }))
  13. What about “union types”/ADTs? There are two primary ways ADTs

    are represented in people’s code today • Constants ◦ “Apple”, “Banana”, “Grape” ◦ Takes some effort, though there are some ways it could be done that are less time-efficient • Tagged Objects ◦ { “tag”: “Fruit”, contents: { “color”: “red” } } ◦ We can do this automatically
  14. Case of string constants data Fruit = Apple | Banana

    | Watermelon derive instance repGenericFruit :: Rep.Generic Fruit _ instance showFruit :: Show Fruit where show = genericShow instance isForeignFruit :: IsForeign Fruit where read x = chooseFruit =<< readString x where chooseFruit s | s == show Apple = pure Apple | s == show Banana = pure Banana | s == show Watermelon = pure Watermelon | otherwise = fail $ ForeignError "We don't know what fruit this is!!!" instance asForeignFruit :: AsForeign Fruit where write = toForeign <<< show newtype RecordWithADT = RecordWithADT { fruit :: Fruit } [...]
  15. And then use it! describe "RecordWithADT" do testJSON (RecordWithADT {

    fruit: Apple }) "{ \"fruit\": \"Watermelon\" }" (Right (RecordWithADT { fruit: Watermelon })) RecordWithADT ✓ can be converted to JSON (RecordWithADT { fruit: Apple }) -> {"fruit":"Apple"} ✓ can be converted back ✓ can be converted from JSON { "fruit": "Watermelon" } -> (Right (RecordWithADT { fruit: Watermelon }))
  16. Case of tagged objects Exactly the same as normal records,except

    one thing data ADTWithArgs = Increment | Add Int | Set { count :: Int } | Reset derive instance genericRepADTWithArgs :: Rep.Generic ADTWithArgs _ instance isForeignADTWithArgs :: IsForeign ADTWithArgs where read = readGeneric defaultOptions instance asForeignADTWithArgs :: AsForeign ADTWithArgs where write = toForeignGeneric defaultOptions Using the default option for unwrapSingleConstructors = false
  17. Result describe "ADTWithArgs" do testJSON (Set { count: 5 })

    "{ \"tag\": \"Add\", \"contents\": 123 }" (Right (Add 123)) ADTWithArgs ✓ can be converted to JSON (Set { count: 5 }) -> {"contents":{"count":5},"tag":"Set"} ✓ can be converted back ✓ can be converted from JSON { "tag": "Add", "contents": 123 } -> (Right (Add 123))
  18. We can do that data TypicalJSTaggedObject = Logout | Login

    { username :: String , password :: String } derive instance genericRepTypicalReduxAction :: Rep.Generic TypicalJSTaggedObject _ What about typical JS-style tagged objects? typicalReduxActionOptions :: Options typicalReduxActionOptions = defaultOptions { sumEncoding = TaggedObject { tagFieldName: "type" , contentsFieldName: "payload" } } instance isForeignTypicalReduxAction :: IsForeign TypicalJSTaggedObject where read = readGeneric typicalReduxActionOptions instance asForeignTypicalReduxAction :: AsForeign TypicalJSTaggedObject where write = toForeignGeneric typicalReduxActionOptions
  19. Result describe "TypicalJSTaggedObject" do testJSON (Login { username: "agent", password:

    "hunter2" }) "{ \"type\": \"Logout\" }" (Right (Logout)) TypicalJSTaggedObject ✓ can be converted to JSON (Login { password: "hunter2", username: "agent" }) -> {"payload":{"username":"agent","password":"hunter2"},"type":"Login"} ✓ can be converted back ✓ can be converted from JSON { "type": "Logout" } -> (Right Logout)
  20. I hope I’ve been able to show that • JSON

    de/serialization can be done largely automatically ◦ Provided your language gives you the tools to do it • You can manually resolve bits as needed ◦ Provided your language gives you the tools to do it • You shouldn’t have to do any of this manually in 2017 ◦ Provided your language gives you the tools to do it • You should write some Purescript too ◦ Or a language of similar power • You should give a similar talk if you’re an F# or OCaml user Conclusion
  21. Thanks! repo: https://github.com/justinwoo/purescript-howto-foreign-generic/