Decode an enumeration from an API in ReScript with decco

May 6, 2024

Decoder composition

A decco decoder must composed of 4 elements:

  • a function encoder which handle serialization
  • a function decoder which handle deserialization
  • a variable codec which contains these functions (as a tuple)
  • a type t who will be the type that we wish to decode/generate

Handle the serialization

When I need to work with a string enumeration, I tend to use the directive @bs.deriving(jsConverter) which generate automaticaly the functions allowing the permutation between a string and a type. Here is an example :

@deriving(jsConverter)
type brand = [
  | #sony
  | #microsoft
  | #toyota
  | #apple
];

Js.log(brandToJs(#microsoft)); /* log "microsoft" */
brandFromJs("microsoft")->Belt.Option.forEach(v => Js.log(v)); /* log the generated id of the type */

A small but interesting details here is the brandFromJs function, it returns an option(string) type because it's possible to give an unexisting enum value and so return the None value.

Let's keep this brand type and write the serialization function :

@deriving(jsConverter)
type brand = [
  | #sony
  | #microsoft
  | #toyota
  | #apple
];

let encoder: Decco.encoder<brand> = (brand: brand) => {
  brand->brandToJs->Decco.stringToJson;
};

Here, I declared the types explicitly to make it clearer but you can also let the inference does it job !

About the encoder function, it takes a parameter of our type we need to convert and transform it in a string in order to invoke the Decco.stringToJson function who will make the JSON conversion.

The serialization is handled ! Nothing more is necessary, we can move on to the deserialization !

Handle the deserialization

It's quite the same like the serialization but with the error case to handle :

let decoder: Decco.decoder<brand> = json => {
  switch (json->Decco.stringFromJson) {
  | Belt.Result.Ok(v) => switch (v->brandFromJs) {
      | None => Decco.error(~path="", "Invalid enum " ++ v, json)
      | Some(v) => v->Ok
    }
  | Belt.Result.Error(_) as err => err
  };
};

In this example, we just need to note that Decco.stringFromJson function return a Belt.Result.t type and to raise an error we need to invoke the Decco.error function.

And the rest

It remains now the 2 variables to create that will be showed like that :

let codec: Decco.codec(brand) = (encoder, decoder);

[@decco]
type t = [@decco.codec codec] brand;

We imperatively associate the right type and here we are, we have our own decoder ! Let's bring this together into a module :

module BrandCodec = {
  @deriving(jsConverter)
  type brand = [
    | #sony
    | #microsoft
    | #toyota
    | #apple
  ]

  let encoder: Decco.encoder<brand> = (brand: brand) => {
    brand->brandToJs->Decco.stringToJson;
  }

  let decoder: Decco.decoder<brand> = json => {
    switch (json->Decco.stringFromJson) {
    | Belt.Result.Ok(v) => switch (v->brandFromJs) {
        | None => Decco.error(~path="", "Invalid enum " ++ v, json)
        | Some(v) => v->Ok
      }
    | Belt.Result.Error(_) as err => err
    }
  }

  let codec: Decco.codec<brand> = (encoder, decoder)

  @decco
  type t = @decco.codec(codec) brand
};

We can now use the t type without using @decco.codec :

/*...*/

@decco
type console = {
  id: string,
  name: string,
  brand: BrandCodec.t
};