Decoding JSON with Typescript

joanllenas

Joan Llenas Mas贸

Posted on December 23, 2018

Decoding JSON with Typescript

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


Enter fullscreen mode Exit fullscreen mode

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 the email.
  • 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" }


Enter fullscreen mode Exit fullscreen mode

alt text

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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 of Ok.

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>


Enter fullscreen mode Exit fullscreen mode

Result<a> is a union type of Ok and Err.



type Result<a> = Ok<a> | Err;


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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!

馃挅 馃挭 馃檯 馃毄
joanllenas
Joan Llenas Mas贸

Posted on December 23, 2018

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

Sign up to receive the latest update from our blog.

Related

Decoding JSON with Typescript
typescript Decoding JSON with Typescript

December 23, 2018