Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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.

Slide 3

Slide 3 text

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 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?

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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 }

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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!

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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 }))

Slide 11

Slide 11 text

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 }

Slide 12

Slide 12 text

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) }))

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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 } [...]

Slide 15

Slide 15 text

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 }))

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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))

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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)

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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