A joy of working with JSON using PureScript
Zelenya
Posted on March 16, 2023
š¹Ā 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" }';
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"}'
// 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...
Dealing with JSON data with unexpected or missing keys is a pain.
undefined undefined undefined
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..."}
]
}}
Letās paste it into the IDE, drop all the null
s, 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..." }
]
}
}
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..." }
]
}
}
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..." }
]
}
}
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
}
}
}
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..." }
]
}
}
If we want, we can loosen this restriction by making the field optional by using the Maybe
data type:
š”Ā 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(..))
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..." }
]
}
}
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..." }
]
}
}
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
]
}
}
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..." } """
š”Ā 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...") })
We use readJSON_
, which tries to parse a JSON string to a typeĀ a
, returningĀ Nothing
Ā if the parsing fails.
š”Ā Using Maybe
s 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") })
The decoding will work when the optional fields are missing:
itemNoLabel :: Maybe MenuItem
itemNoLabel = readJSON_ """ { "id": "About" } """
-- (Just { id: "About", label: Nothing })
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
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 } }
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"
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"} }
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 } }
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.
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" }
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:
Posted on March 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.