Decoding JSON with Typescript
Joan Llenas Mas贸
Posted on December 23, 2018
Typescript is very well suited for adding type safety to a JavaScript program, but on its own, it's not enough to guarantee that it won't crash at run-time.
This article shows how JSON decoders can help to extend TypeScript compile-time guarantees to the run-time environment.
Runtime vs Compile time
The application you are working on deals with users, so you create a User
type:
interface User {
firstName: string;
lastName: string;
picture: string;
email: string;
}
You'll use this type to annotate the /me
API endpoint result, and then you'll do all sorts of things with this User
but let's concentrate on the profile area of the app:
- It'll display the concatenation of
firstName
+lastName
. - Below the
firstName
+lastName
you also want to display theemail
. - Finally, you want to show the
picture
of the user or, if not present, a default image.
What could go wrong? Well, for starters the User
type is not telling the truth, It does not express all the permutations of User
shapes that the API can return.
Let's see a few examples:
// The result has null properties
{ firstName: "John", lastName: null, picture: null, email: "john@example.com" }
// The API returned null
null
// The result has undefined properties
{ firstName: "John", lastName: "Doe", email: "john@example.com" }
// The API contract changed and the UI team wasn't notified
{ fName: "John", lName: "Doe", picture: 'pic.jpg', email: "john@example.com" }
You can cope with these issues by using defensive programming techniques, a.k.a. null
/ undefined
checks behind if
statements but, what happens when someone else wants to use the /me
result elsewhere? Maybe your colleague trusts the User
type, why not? What happens then? We introduced a new vector for runtime errors.
Enter Json decoders
You use Json decoders to make sure that a given run-time value complies with a specific compile-time type, and not only that but also gives you tools to apply transformations, failovers and more.
Json decoders have gained popularity lately thanks to Elm.
Elm's Json decoders are a core part of the language, and they are used all over the place to ensure a smooth JS to Elm communication.
The idea behind Json decoders is that you have a collection of basic decoders ( string
, number
, boolean
, object
, array
... ) that you can compose into more complex decoders.
State-of-the-art JSON decoder libraries
There are a few JSON decoding libraries out there, but there's one that stood out from the rest when I made the research a while ago. Daniel Van Den Eijkel created something that kept the principles of the Elm decoding library while being idiomatic in TypeScript terms.
Unfortunately, the library was unmaintained and unpublished, so I decided to fork it, polish it, and release it as an npm package under the name ts.data.json.
My contribution to the library has been documentation, better error reporting, unit testing, API improvements, a few new decoders and publishing the npm package.
Using JSON decoders
Install the library:
npm install ts.data.json --save
Decoding basics
Before implementing our custom User
decoder let's try decoding a string
from start to finish.
import { JsonDecoder } from 'ts.data.json';
console.log( JsonDecoder.string.decode('Hi!') ); // Ok({value: 'Hi!'})
Finished! 馃帀
Unwrapping decoder results
As we saw in our previous example the decoding process has two steps.
- First, we declare the decoder with
JsonDecoder.string
. - Second, we execute the decoder passing a JavaScript value with
*.decode('Hi!')
, which returns the result wrapped in an instance ofOk
.
Why are we wrapping the result in an Ok
instance? because in case of failure we would wrap the result in an Err
instance.
Let's see how the decode()
signature looks like:
decode(json: any): Result<a>
Result<a>
is a union type of Ok
and Err
.
type Result<a> = Ok<a> | Err;
So most of the time we won't be using decode()
, instead we'll probably want to use decodePromise()
.
Let's see how the decodePromise()
signature looks like:
decodePromise<b>(json: any): Promise<a>
Let's try decoding a string
from start to finish using decodePromise()
:
import { JsonDecoder } from 'ts.data.json';
const json = Math.random() > 0.5 ? 'Hi!' : null;
JsonDecoder.string.decodePromise(json)
.then(value => {
console.log(value);
})
.catch(error => {
console.log(error);
});
Half of the time we'll go through the then()
route and get Hi!
, and half of the time we'll go through the catch()
route get null is not a valid string
.
Now that we know the basics let's get serious and build our custom User
decoder.
The User
decoder
Aside from the primitive decoders:
JsonDecoder.string: Decoder<string>
JsonDecoder.number: Decoder<number>
JsonDecoder.boolean: Decoder<boolean>
there are also other more complex decoders, and for our User
we'll be using the JsonDecoder.object
decoder:
JsonDecoder.object<a>(decoders: DecoderObject<a>, decoderName: string): Decoder<a>
What's that
Decoder<a>
thing all decoders are returning?Decoders have the logic to decode a particular value, but they don't know how to execute it, this is what the
Decoder
class is for.
Decoder<a>
has methods to execute, unwrap, chain and transform decoders / decoder values.
Let's try decoding a User
from start to finish using all the tricks we've learned so far:
import { JsonDecoder } from 'ts.data.json';
interface User {
firstName: string;
lastName: string;
}
const userDecoder = JsonDecoder.object<User>(
{
firstName: JsonDecoder.string,
lastName: JsonDecoder.string
},
'User'
);
const validUser = {
firstName: 'Nils',
lastName: 'Frahm'
};
const invalidUser = {
firstName: null,
lastName: 'Wagner'
};
const json = Math.random() > 0.5 ? validUser : invalidUser;
userDecoder
.decodePromise(json)
.then(value => {
console.log(value);
})
.catch(error => {
console.log(error);
});
Half of the time we'll get {firstName: "Nils", lastName: "Frahm"}
and half of the time we'll get <User> decoder failed at key "firstName" with error: null is not a valid string
. JsonDecoder
has us covered.
Going down the rabbit hole
We just started to scratch the surface of what this library is capable of, there are decoders for every type you could imagine. You can also decode:
- arrays
- dictionaries
- recursive data structures
- null
- undefined
and other fancy stuff.
Go to the GitHub repo and find out!
Posted on December 23, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.