the merit of strictly typed JSON

The programming language of the web is JavaScript (JS), but the lack of static types is the biggest pain point highlighted in the 2023 State of JS survey. We rely on TypeScript (TS) to achieve strong structural type analysis of our code, as noted in our standardization article. Static types help prevent runtime errors on our users’ machines. However, to ensure we never throw a TypeError, we must avoid the loose type `any`, which is an escape route designed to bypass TS’s static analysis.

JavaScript Object Notation (JSON) is widely used as a data interchange format for REST APIs. Its syntax is compatible with JS, using only a subset of the language’s primitives: objects, arrays, strings, numbers, and zeros. Simple objects can be converted to a string with JSON.stringify and back to an object with JSON.parse, making it a suitable choice for many applications. We use it to power our comments, new live blog updates, soccer match scores, weather, most read, and other website and app features.

How things can go wrong

The TS compiler can analyze code against type description objects and functions. For code you write, this is done via annotations alongside your code, using native syntax or JSDocs. For browser APIs, Microsoft provides DOM declaration files that align with versions of the ECMAScript specification. For example, calls to `JSON.parse` return `any` in ES5. As noted above, this means that these objects are disabled for static analysis. This means that we can no longer rely on the compiler to catch errors that might occur on user devices before we publish our code.

const object = JSON.parse(`{ "key": "value", "year": 1821, }`) // TypeScript is unable to catch that accessing // these keys will throw a TypeError: // can't access property "shape", "wrong" is undefined object.wrong.shape; 

Things can get even more confusing if you assign a type to the parsed object, since the TS compiler will use that value in the future and change the ‘any’ to a specific form. Such errors are difficult to debug because they only occur when your code accesses the undefined properties, rather than when you receive a JSON response from the REST API. Because errors break execution all the way to the nearest try…catch statement, they can break large parts of your user interactions. These errors can occur because you inadvertently declared the wrong form, but they can also appear if the API changes its schema. If you chose TS for its ability to prevent runtime errors, these kinds of surprises are exactly what you were trying to avoid in the first place.

Check if everything is as expected

“For some developers it may be more informative to see a production implementation, so we’ve implemented an example of this pattern in dotcom-rendering#11835”

This problem has been experienced by many developers and the easiest way to force TS to warn about potential problems is to explicitly assign the ‘unknown’ type to the object returned by JSON.parse. There is a proposal to make this the default in TS and a custom declaration library to ensure your code handles any unexpected object form gracefully.

TS’s control flow analysis allows you to check whether each of the properties is as expected by using the ‘typeof’ operator recursively and accessing only valid properties in nested conditional blocks. However, this can easily lead to repetitive boilerplate for objects with complex shapes.

async function getTemperature(): Promise { const response = await fetch('https://api.nextgen.guardianapps.co.uk/weather.json') const data: unknown = await response.json(); // we must check that the data has a specific shape if (typeof data === 'object' && data != null && 'weather' in data) { if (typeof data.weather === 'object' && data.weather != null && 'temperature' in data.weather) { if (typeof data.weather.temperature === 'object' && data.weather.temperature != null && 'metric' in data.weather.temperature) { if (typeof data.weather.temperature.metric === 'number') { return data.weather.temperature.metric; } } } } throw TypeError('No valid temperature'); }

Fortunately, many developers have encountered this problem, and there are numerous parsing libraries that provide more ergonomic APIs for these operations. To integrate with the TS compiler, they generally expose custom schemas that describe the expected shape of the data, against which the unknown objects can then be validated. Unlike TS, their work is done at runtime, on the user’s device, so the size of the library and its performance must be taken into account. We therefore chose Valibot, which has a similar API to the popular Zod library with a generally much smaller footprint. Comparing the declarative schema below with the imperative checks above shows a clear improvement in readability, and this distinction increases with the complexity of the data model.

import { parse, object, number } from 'valibot'; const schema = object({ weather: object({ temperature: object({ metric: number(), }), }), }); async function getTemperature(): Promise { const response = await fetch('https://api.nextgen.guardianapps.co.uk/weather.json') const data = parse(schema, await response.json()); // the TS compiler is happy accessing this object // and the parsing library ensures that it actually is return data.weather.temperature.metric; } 

We used this approach for our comments, which allowed us to make changes with confidence, because we now have the guarantee that the data model will actually have the shape that the TS compiler expects. This is especially useful because the team implementing the web interface is different from the team providing the JSON API, which might evolve its data model over time. Now a breaking change is caught at the parsing step and handled there.

Fail with grace

If the data does not match the schema, the ‘parse’ function will throw an error. While this will prevent further errors from being thrown, it still stops the code from executing and can break other interactions on the website. To avoid exceptions and handle this case, we can treat errors as values ​​by using the ‘safeParse’ helpers, which return a tagged union that allows code to check for success or failure. To avoid relying too much on a specific library’s implementation, we wrap this in our custom ‘Result’ type so that this pattern becomes more familiar to developers as it can be used outside of the specific parsing cases that can fail.

When making a network request to a REST API, several other types of errors can occur: dropped or timed network requests, a backend server error, or an invalid JSON string. By using a consistent pattern for handling errors, we can display the most appropriate messages to our users. For example, we can offer to retry the operation or provide a clear explanation of why the error occurred.

Leave a Comment