A joy of working with JSON using PureScript

zelenya

Zelenya

Posted on March 16, 2023

A joy of working with JSON using PureScript

šŸ“¹Ā Hate reading articles? Check out the complementary video, which covers the same content: https://youtu.be/fXCYKqcX1Bc


Iā€™ve been working as a Fullstack engineer for a while, and it feels like we're shuffling JSON most of the coding time.

I want to cover how annoying it is to deal with JSON using JavaScript (and other languages) and share how joyful it is to work with JSON using PureScript.

Iā€™ll focus on JSON, but by the end, you should be able to guess and decide how it applies to other formats.

Status quo

Problems when working with JSON using JavaScript

Letā€™s start with the problems first.

Just by looking at its name, JSON might give us the impression that JavaScript is the perfect language for it. But itā€™s not as lovely as it sounds ā€“ several issues exist.


šŸ’”Ā JSON stands for JavaScript Object Notation


As a reminder, the JSON syntax resembles JavaScript objects, but they are not the same and are not interchangeable.

// I am a JS object
const jsData = { "name": "Jason", "quest": "Golden Fleece" };

// I am a JSON
const jsonData = '{ "name": "Jason", "quest": "Golden Fleece" }';
Enter fullscreen mode Exit fullscreen mode

Parsing and encoding JSON can be tedious and error-prone. The built-in functions are difficult to work with and don't allow much customization or error handling.

// Convert to JSON:
JSON.stringify(jsData);
// '{"name":"Jason","quest":"Golden Fleece"}'
Enter fullscreen mode Exit fullscreen mode
// Convert to JavaScript object:
JSON.parse(jsonData);
// {name: 'Jason', quest: 'Golden Fleece'}

const badData = '{ 42: "field"}';
JSON.parse(badData);
// Uncaught SyntaxError: Expected property name or '}' in JSON...
Enter fullscreen mode Exit fullscreen mode

Dealing with JSON data with unexpected or missing keys is a pain.

undefined undefined undefined
Enter fullscreen mode Exit fullscreen mode

Working with nested JSON can be particularly challenging. Sometimes itā€™s just so much manual labor.

And here is a controversial byte. One of my main issues with JSON in JavaScript is dynamic typing. Itā€™s difficult (if not impossible) to ensure that the parsed JSON data conforms to a specific interface or type.

Donā€™t even get me started on dealing with something specific, like dates.

All of these lead to many bugs and errors in code that deals with JSON.

What if we use JSON Schema? It can help validate data but wonā€™t solve all the issues.

And what if we use TypeScript?

Problems when working with JSON using TypeScript

TypeScript adds static typing and provides many other benefits over JavaScript, but working with JSON in TypeScript can still be problematic.

We can define interfaces and types in TypeScript. Still, the support for custom data types is limited and ensuring that the parsed JSON data matches the defined interface or type can be challenging. Properly handling errors doesnā€™t get easier, either.

So, it is still time-consuming and error-prone, making the code less maintainable.

Some libraries make it nicer. But letā€™s skip ahead to something that is next level nicer.

Working with JSON using PureScript

PureScript offers a more powerful and flexible set of tools for working with JSON data.

To illustrate this, Iā€™ll show you how I usually prototype with PureScript.

Letā€™s grab a random sample JSON, for example, fromĀ json.org/example.html:

{"menu": {
    "header": "SVG Viewer",
    "items": [
        {"id": "Open"},
        {"id": "OpenNew", "label": "Open New"},
        null,
        {"id": "ZoomIn", "label": "Zoom In"},
        {"id": "ZoomOut", "label": "Zoom Out"},
        {"id": "OriginalView", "label": "Original View"},
        null,
        {"id": "Quality"},
        {"id": "Pause"},
        {"id": "Mute"},
        null,
        {"id": "Find", "label": "Find..."},
        {"id": "FindAgain", "label": "Find Again"},
        {"id": "Copy"},
        {"id": "CopyAgain", "label": "Copy Again"},
        {"id": "CopySVG", "label": "Copy SVG"},
        {"id": "ViewSVG", "label": "View SVG"},
        {"id": "ViewSource", "label": "View Source"},
        {"id": "SaveAs", "label": "Save As"},
        null,
        {"id": "Help"},
        {"id": "About", "label": "About Adobe CVG Viewer..."}
    ]
}}
Enter fullscreen mode Exit fullscreen mode

Letā€™s paste it into the IDE, drop all the nulls, and try assigning it to a variable.

jsonSample =
  { "menu":
      { "header": "SVG Viewer"
      , "items":
          [ { "id": "Open" }
          , { "id": "OpenNew", "label": "Open New" }
          , { "id": "ZoomIn", "label": "Zoom In" }
          , { "id": "ZoomOut", "label": "Zoom Out" }
          , { "id": "OriginalView", "label": "Original View" }
          , { "id": "Quality" }
          , { "id": "Pause" }
          , { "id": "Mute" }
          , { "id": "Find", "label": "Find..." }
          , { "id": "FindAgain", "label": "Find Again" }
          , { "id": "Copy" }
          , { "id": "CopyAgain", "label": "Copy Again" }
          , { "id": "CopySVG", "label": "Copy SVG" }
          , { "id": "ViewSVG", "label": "View SVG" }
          , { "id": "ViewSource", "label": "View Source" }
          , { "id": "SaveAs", "label": "Save As" }
          , { "id": "Help" }
          , { "id": "About", "label": "About Adobe CVG Viewer..." }
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

Right away, the compiler tries to guess the type of the data, but because some of the objects have the field "label" and some donā€™t, the compiler brings this to our attention, so we need to make a decision:

  • Is label an optional field?
  • Or is label required, and there are problems with the data?

But if we had a simpler JSON, with only required fields, it would be able to suggest a type:

json =
  { "menu":
      { "header": "SVG Viewer"
      , "items":
          [ { "id": "About", "label": "About Adobe CVG Viewer..." }
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

ide suggestion

We can use this suggestion from the language server to get a free type:

json
  :: { menu ::
         { header :: String
         , items ::
             Array
               { id :: String
               , label :: String
               }
         }
     }
json =
  { "menu":
      { "header": "SVG Viewer"
      , "items":
          [ { "id": "About", "label": "About Adobe CVG Viewer..." }
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

Next, we can refactor it ā€“ extract this type and use it for other values ā€“ to make it nicer:

type Menu =
  { menu ::
      { header :: String
      , items ::
          Array
            { id :: String
            , label :: String
            }
      }
  }
Enter fullscreen mode Exit fullscreen mode

Now, the type says that the "label" is required, so the sample data is wrong because some objects lack this field:

jsonSample :: Menu
jsonSample =
  { "menu":
      { "header": "SVG Viewer"
      , "items":
          [ { "id": "SaveAs", "label": "Save As" }
          , { "id": "Help" }
--        ^^^^^^^^^^^^^^^^^^
--        Compiler error: Lacks required field "label"
          , { "id": "About", "label": "About Adobe CVG Viewer..." }
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

If we want, we can loosen this restriction by making the field optional by using the Maybe data type:

ide type error


šŸ’”Ā TheĀ MaybeĀ type represents optional values, like a type-safeĀ null, where Nothing corresponds toĀ nullĀ andĀ Just xĀ ā€“ the non-null valueĀ x.


We must adopt the code and make the value ā€œpresenceā€ explicit.

import Data.Maybe (Maybe(..))
Enter fullscreen mode Exit fullscreen mode
jsonSample :: Menu
jsonSample =
  { "menu":
      { "header": "SVG Viewer"
      , "items":
          [ { "id": "SaveAs", "label": Just "Save As" }
          , { "id": "Help", "label": Nothing }
          , { "id": "About", "label": Just "About Adobe CVG Viewer..." }
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

This might look tedious, but itā€™s only because Iā€™m trying to show you the code and walk you through the steps. Also, weā€™re modifying a PureScript record, not a JSON.


šŸ’”Ā Records correspond to JavaScript's objects; record literals have the same syntax as JavaScript's object literals.


šŸ’”Ā We can drop the quotations around the field labels:

jsonSample :: Menu
jsonSample =
  { menu:
      { header: "SVG Viewer"
      , items:
          [ { id: "SaveAs", label: Just "Save As" }
          , { id: "Help", label: Nothing }
          , { id: "About", label: Just "About Adobe CVG Viewer..." }
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

Usually, you just use the library for stuff like this. Weā€™ll showcase one of the neat libraries in the next section.

One of the most significant benefits of working with JSON in PureScript is how easy itā€™s to create types that correspond directly to JSON objects, which empowers the compiler to detect any mismatches between the actual data and the expected type, making it easier to catch errors and guarantee that the code works as expected.

jsonSampleWithTypo :: Menu
jsonSampleWithTypo =
  { menu:
      { header: "SVG Viewer"
      , items:
          [ { id: "SaveAs", label: Just "Save As" }
          , { id: "Help", label: Nothing }
          , { id: "About", labe: Just "About Adobe CVG Viewer..." }
--                         ^^^^
--                         Compiler error          
          ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

PureScript allows us to ensure that we can correctly parse the incoming JSON and validate that all the required fields are present.

JSON libraries

PureScript also offers several libraries for working with JSON, such as purescript-yoga-json, which provides JSON encoding and decoding functions that are pretty flexible and customizable.

JSON decoding

First, letā€™s start with proper decoding. Imagine we fetched a string from an external service or a database (but here, itā€™s just a mock):

rawJson :: String
rawJson = """ { "id": "About", "label": "About Adobe CVG Viewer..." } """
Enter fullscreen mode Exit fullscreen mode

šŸ’”Ā We can use """ to wrap multiline strings.


We donā€™t have to bother with any decoders or parsers or whatever. We just specify what we care about as a type and use a library function:

import Yoga.JSON (readJSON_)

type MenuItem =
  { id :: String
  , label :: Maybe String
  }

item :: Maybe MenuItem
item = readJSON_ rawJson
-- (Just { id: "About", label: (Just "About Adobe CVG Viewer...") })
Enter fullscreen mode Exit fullscreen mode

We use readJSON_, which tries to parse a JSON string to a typeĀ a, returningĀ NothingĀ if the parsing fails.


šŸ’”Ā Using Maybes is nice and straightforward for prototyping but can be limiting in production because we throw away the actual error.

The library provides two other functions that allow different kinds of error handling; for instance, a function returns a list of all the parsing errors in case of a failure.


When parsing, extra fields are going to be ignored and not parsed:

itemExtra :: Maybe MenuItem
itemExtra = readJSON_ """ { "id": "About", "label": "About Viewer", "extra": "fields are ignored" } """
-- (Just { id: "About", label: (Just "About Viewer") })
Enter fullscreen mode Exit fullscreen mode

The decoding will work when the optional fields are missing:

itemNoLabel :: Maybe MenuItem
itemNoLabel = readJSON_ """ { "id": "About" } """
-- (Just { id: "About", label: Nothing })
Enter fullscreen mode Exit fullscreen mode

But it wonā€™t work when the JSON doesnā€™t have the required field or is completely broken:

itemWrong :: Maybe MenuItem
itemWrong = readJSON_ """ { "identity": "About" } """
-- Nothing 

itemBroken :: Maybe MenuItem
itemBroken = readJSON_ """ { 42: "About" } """
-- Nothing
Enter fullscreen mode Exit fullscreen mode

If we use an alternative function, we can see the actual errors and act on them in particular ways:

  • The first one would return an error about missing fields.
  • The second one would return an error about a broken JSON.

Which we can handle differently according to the business domain.


JSON encoding is also as simple, so letā€™s just skip it.

These libraries enable developers to handle unexpected data more gracefully and customize the encoding and decoding process to fit their needs.

Working with JSON

Because parsed JSONs are PureScript records, we donā€™t need a library to work with them; the standard library provides excellent tools for numerous modification tasks.

For example, letā€™s write a simple function that can be used to set the header field:

setHeader :: String -> Menu -> Menu
setHeader newHeader config =
  config { menu { header = newHeader } }
Enter fullscreen mode Exit fullscreen mode

We can use it with the sample JSON:

emptyHeadedMenu = setHeader "empty" jsonSample
-- { menu: { header: "empty", items: [{ id: "SaveAs", label: (Just "Save As") },{ id: "Help", label: Nothing },{ id: "About", label: (Just "About Adobe CVG Viewer...") }] } }

emptyHeadedMenu.menu.header
-- "empty"
Enter fullscreen mode Exit fullscreen mode

And we donā€™t need to clone anything, no need for getters or setters ā€“ we use standard record update syntax.

The cool thing is that we can easily make this function work for various JSONs. If we drop the specialized type signature, the function becomes generic:

setAnyHeader newHeader config =
  config { menu { header = newHeader } }

setAnyHeader "empty" jsonSample
-- { menu: { header: "empty", items: [{ id: "SaveAs", label: (Just "Save As") },{ id: "Help", label: Nothing },{ id: "About", label: (Just "About Adobe CVG Viewer...") }] } }

setAnyHeader "empty" { menu: { header: "Head", body: "Body" } }
-- { menu: { header: "empty", body: "Body"} }
Enter fullscreen mode Exit fullscreen mode

This is a different type of data, but the function still works.


šŸ’”Ā We can add an explicit type signature:

setAnyHeader
  āˆ· String
  ā†’ { menu āˆ· { header :: String | _ } | _ }
  ā†’ { menu āˆ· { header :: String | _ } | _ }
setAnyHeader newHeader config =
  config { menu { header = newHeader } }
Enter fullscreen mode Exit fullscreen mode

Which says that the function takes and returns any record that has a field menu that is any record that has a text field header.

The function doesnā€™t care about the rest of the fields and keeps them as is.


Also, the typos and invalid data wonā€™t compile:

setAnyHeader "empty" { menu: { head: "Head", body: "Body" } }
--                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- Compile error: Type of expression lacks required label header.
Enter fullscreen mode Exit fullscreen mode

Moreover, PureScript provides a standard package called record for working with records, including neat functions for modifying records, removing duplicate labels, renaming labels, etc.

One of my favorites is union, which we can use to merge records, for example, if they come from different services or pipelines. Here is an example:

menuPart = { menu: { header: "SVG Viewer", body: "Body" } }

metaPart = { metaInfo: "Additional information" }

whole = union menuPart metaPart
-- { menu: { body: "Body", header: "SVG Viewer" }, metaInfo: "Additional information" }
Enter fullscreen mode Exit fullscreen mode

Conclusion

Dealing with data, such as JSON, is one of the areas where using a language like PureScript can be beneficial, as it provides more robust and customizable tooling.

You can be in control of the data and error-handling. And you can forget about running into undefined at runtime.


šŸ’”Ā Links, recaps, and cheat sheets:

šŸ’– šŸ’Ŗ šŸ™… šŸš©
zelenya
Zelenya

Posted on March 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related