Spotted Arrow

2019-02-14

Writing Encoders/Decoders in ReasonML

The other day I wrote some encoders/decoders in Reason and would like to document my journey doing so.

In JavaScript, JSON works just fine in the browser environment. For example, when you make a fetch call, you can use the Fetch API’s Body.json function to convert a completed response stream into JSON. You can copy/pasta the code below into your browser console.

fetch("https://aws.random.cat/meow")
  .then((response) => response.json())
  .then((data) => {
    console.log("file:", data.file); // This is fine!
  });

Reference: https://developer.mozilla.org/en-US/docs/Web/API/Body/json

This is not so easy in languages like ReasonML, Elm, or PureScript. The data needs to be converted into the data types those languages understand. This is where encoders/decoders step in. Decoders convert data from a foreign type, like JSON, into idiomatic types like ReasonML’s bool. On the flip side, encoders take domain types, like ReasonML’s int, and convert them into types that other domains can understand, like JSON.

In JavaScript, how do you know the shape of the data you’re getting back from the server? And even when you do know the shape of the data, how do you know you’re using it correctly. Yes, you have things like TypeScript to model the shape of your data, but even then, how can you ensure that the data you are receiving is fool-proof from breaking the app at run-time? This is where encoders/decoders shine. Why? Because these functions check at run-time whether the data is in the shape you are decoding/encoding. If it’s not, then the decoder will throw an error. This error is handled upstream, so any usage of the data down stream in your app is not effected.

In this article, we are going to look at how handle JSON in ReasonML using the well-know bs-json library.

FYI, the source code is in Ocaml. That syntax has tripped me up sometimes. If that messes you up too much check out this handy browser extension that converts Ocaml to ReasonML and vice versa:

There is a Chrome extension I believe but I am an unapologetic Firefox user so there you go.

Next, let’s create a development environment. You will need a computer, ReasonML, and bs-json. To install ReasonML go here:

Then in your terminal setup a simple project.

$ bsb -init learn-to-decode -theme basic-reason && cd learn-to-decode && yarn install && yarn add @glennsl/bs-json

Next, add bs-json to your bsconfig.json.

"bs-dependencies": ["[@glennsl/bs-json](http://twitter.com/glennsl/bs-json "Twitter profile for @glennsl/bs-json")"],

Let’s test things out and make sure they are working.

$ npm run build
$ node src/Demo.bs.js  // output: Hello, BuckleScript and Reason!

Since you need something to actually decode, let’s create a nested JSON type using some BuckleScript helpers. The structure will be something like this in JSON:

{
  info: {
    name: "Adam";
    hobbies: ["writing", "sleeping"];
    isYoloing: null;
  }
}

To do this quickly in BuckleScript you can use Js.Dict.fromList where each value is of type Js.Json.t. Dictionaries in ReasonML are like Maps in other languages. You can have different keys but the values need to be of the same type.

let info =
  Js.Dict.fromList([
    ("name", Js.Json.string("Adam")),
    ("hobbies", Js.Json.stringArray([|"writing", "sleeping"|])),
    ("isYoloing", Js.Json.null),
  ]);

let person = Js.Dict.fromList([("info", Js.Json.object_(info))]);

let json = Js.Json.object_(person);

Js.log(json);
// {
//  info: {
//   name: 'Adam',
//   hobbies: [ 'writing', 'sleeping' ],
//   isYoloing: null
//  }
// }

Once last thing. If you get lost the full repository is here:

Now that we have something to actually decode let’s create a decoder for this bad boy data structure. Let’s look at our data types. There is a string, an array of strings, and a null value. These are packaged in an object.

We can start by modeling our data in ReasonML using records. Records are more concrete types and it allows us to take JS/JSON data and make it into more idiomatic ReasonML.

type info = {
  name: string,
  hobbies: list(string),
  isYoloing: option(bool),
};

type person = {info};

You should know by now that there is no null in ReasonML. Instead, this is handled as a option. Arrays exist in ReasonML but I like working with Lists instead. Now we can start to write out decoder.

let decodeInfo = json =>
  Json.Decode.{
    name: json |> field("name", string),
    hobbies: json |> field("hobbies", list(string)),
    isYoloing: json |> field("isYoloing", optional(bool)),
  };
let decodePerson = json =>
  Json.Decode.{info: json |> field("info", decodeInfo)};

What’s nice about Reason is that by writing the types above, it can infer the decoder you are trying to write. Try stubbing out hobbies from inside decodeInfo and the compiler will yet at you. Pretty nice!

The Json.Decode.{} function means you are decoding an object and in that object you have the individual fields you want to decode. You use the field function to accept a key and a decoder. This decoder will convert the value into Reason. The bs-json library provides a base set of decoders to get started like list, string, optional, bool, and so on. From these, you can build more complex decoders like what you see there with decodeInfo.

Let’s try out our decoder and see what happens.

let decoded = decodePerson(json);

Js.log(decoded); // [ [ 'Adam', [ 'writing', [Array] ], undefined ] ]

What the bloody hell is that!? That my friend is what Reason code looks like in JS. BuckleScript does some funny things to it. Records look like arrays, lists look like linked lists, and option looks like undefined. This stuff is optimized so even though it looks crazy, it’s not.

Now that you have your value encoded, let’s do the opposite and encode that value into a string. You might do this if you were trying to save information to local storage like I was.

let encodeInfo = (info) =>
  Json.Encode.object_([
    ("name", Json.Encode.string(info.name)),
    ("hobbies", Json.Encode.list(Json.Encode.string, info.hobbies)),
    ("isYoloing", Json.Encode.nullable(Json.Encode.bool, info.isYoloing)),
  ]);

let encodePerson = (person) =>
  Json.Encode.object_([("info", encodeInfo(person.info))]);

The syntax is very much the same as they were for decoders with a slight difference. Here, instead of using the .{} syntax we are using .object_([]). Instead of passing in json to create the value, we pluck off the specific values from the record and pass those to the encoders. The .object_([]) function works similar to the dictionary we used earlier to create JSON. In some cases, you have to pass an additional value to the encoder, one for the encoding the internal value as you see in hobbies.

Now let’s check out what we have made.

let encoded = Json.stringify(encodePerson(decoded));

Js.log(encoded); // {"info":{"name":"Adam","hobbies":["writing","sleeping"],"isYoloing":null}}

Now this can be placed nice and neatly in local storage.

In this article, I hope you learned how to create encoders/decoders in ReasonML. If you didn’t, that’s on you, not me 😄. These functions can come in really handy when you need to convert data from the server into usable Reason types.

This article has Webmentions