ReScript records, NextJS, undefined and getStaticProps

ryyppy

Patrick Ecker

Posted on May 15, 2021

ReScript records, NextJS, undefined and getStaticProps

NextJS, a pretty solid framework for building React based websites and web-applications, offers a nice feature for generating static pages from e.g. fetched endpoint data via the getStaticProps API, which looks something like this in JavaScript:

export default function MyComponent(props) {
  // props provided by getStaticProps
}

export async function getStaticProps(context) {
  // do your async stuff here to fetch data
  return {
    props: {}, // pass the data as props to your component
  }
}
Enter fullscreen mode Exit fullscreen mode

There is an important restriction though: The value defined as props must be JSON serializable. JavaScript objects usually resemble JSON data by default, so oftentimes this isn't really an issue. There are still some subtle cases where confusing errors pop up, so this article will describe a typical error scenario a ReScript developer will most likely face when working with NextJS.

The JSON Issue w/ undefined values

Let's assume we want to use the getStaticProps API and return some props based on a ReScript record:

// ReScript code

type recipe = {
  diet_kind: option<string>
};

let getStaticProps = (_ctx) => {
  let recipe = {
    diet_kind: None
  };

  //last statement is the return value
    {
      "props": recipe
    }
};

Enter fullscreen mode Exit fullscreen mode

As soon as you compile this code and boot up the Next development server, you will get an error similar to this:

Error: Error serializing `.diet_kind` returned from `getStaticProps` in "/".

ReScript: `undefined` cannot be serialized as JSON. Please use `null` or omit this value all together.
Enter fullscreen mode Exit fullscreen mode

Let's explain what's going on here.

First of all, a ReScript record will compile to a JS object with the same object structure. The diet_kind defined as option<string> may be one of two different values, which compile to the following JS:

  • Some("My Diet Name") will be compiled to "My Diet Name"
  • None will be compiled to undefined

As soon as I construct a recipe value { diet_kind: None }, it will be compiled to { diet_kind: undefined }, which is not a well defined JSON value.

There are two solutions on how to tackle this (as already noted by the error message above), and I'd like to show how to do this specifically in ReScript.

Use null instead of undefined

Instead of using option, we need to fall back to the JS specific interop type Js.Nullable.t:

type recipe = {
  diet_kind: Js.Nullable.t(string)
};

let myRecipe = {
  diet_kind: Js.Nullable.null
};
Enter fullscreen mode Exit fullscreen mode

This will compile myRecipe into { diet_kind: null } in JS, which now is valid JSON. This solution is functional but somewhat impractical. Pure ReScript code doesn't have any null values and uses options everywhere for expressing existing / non-existing values. So every time we want to use a Next based API, we'd need to map and convert those options to nullable types back and forth.

For example, on the component side, we'd now need to handle the recipe value like this:

type props = recipe;

// Interop React component without react.component ppx
let default = (props: props): React.element => {
     // Convert diet_kind back to option
   let diet_kind = Js.Nullable.toOption(props.diet_kind);

   switch(diet_kind) {
     | Some(kind) => Js.log2("here is the kind: ", kind)
     | None => Js.log("No kind found")
   };

   <div/>;
};
Enter fullscreen mode Exit fullscreen mode

This doesn't really feel like a very accessible approach, so I was curious if there are other ways to do it. The alternative is a little bit more hacky and unsafe though.

Omit all undefined values all together

The other approach would be to completely strip every object attribute that is set to undefined. This is actually hard to do for pure ReScript code, since the JS object representation of a record will always maintain the full structure with all its attributes attached, even if they are undefined.

That's why we'll need to go the JS interoperability route and use unsafe code to strip away those values. getStaticProps is only used during build time, so I think it's okay to use the JSON.stringify / JSON.parse functions to do the dirty work for us. As a reference, that's what we want to do in JS:

const v = { a: undefined, b: "test" };
const json = JSON.parse(JSON.stringify(v));

// json = { b: "test" }
Enter fullscreen mode Exit fullscreen mode

As you can see, JSON.stringify drops all the values that are not part of the JSON definition. It's also a quite generic approach, since it strips all non-JSON values recursively, and can be applied to any json data object or array.

Before proceeding to the interesting part, here's a word of caution: In ReScript it's often a trade-off between type-safety and convenience. The solutions proposed here are unsafe by design for practical reasons. If you are striving for a 100% type-safe codebase, this approach is not for you.

---------------------UNSAFE PART FROM HERE ---------------------

Somewhat unsafe undefined stripping for ReScript records

Let's get back to our recipe example with option values. We will start out putting everything in a Recipe module so we can easily add related functions for our recipe data type t:

module Recipe = {
  type t = {
      title: string,
      diet_kind: option<string>
    };
}
Enter fullscreen mode Exit fullscreen mode

Now let's define an independent function stripUndefined for the stringify / parse logic:

let stripUndefined = (json: Js.Json.t): Js.Json.t => {
  open Js.Json
  stringify(json)->parseExn
};
Enter fullscreen mode Exit fullscreen mode

Since stripUndefined defines Js.Json.t values as its input and output, we need to add very unsafe external functions to our Recipe module:

module Recipe = {
  type t = {
    title: string,
      diet_kind: option<string>,
    };
    external fromJson: Js.Json.t => t = "%identity";
    external toJson: t => Js.Json.t = "%identity";
}

let stripUndefined = (json: Js.Json.t): Js.Json.t => {
  Js.Json.(stringify(json)->parseExn);
};
Enter fullscreen mode Exit fullscreen mode

Note: As mentioned before, a ReScript record is represented as a JS object during runtime (so basically a JSON structure, if we only use JSON related values inside). With the fromJson and toJson identity externals, we are lying to the compiler that our type Recipe.t is equivalent to a Js.Json.t, so this is completely unsafe and it should be made sure that your type t really is handling values that are Json compliant (except undefined values of course). That means you should only use values such as string, float, object, array, and of course option (but no other values such as functions, otherwise they will be stripped as well).

Now let's combine all these things together to see it in action:

let myRecipe = {
  Recipe.title: "My Recipe",
  diet_kind: None
};

let default = (props: recipe) => {
  // No convertion from Js.Nullable needed anymore!
  switch(diet_kind) {
   | Some(kind) => Js.log2("here is the kind: ", kind)
   | None => Js.log("No kind found")
  };
    <div/>;
};

// Simulating the getStaticProps Api without any types here
let getStaticProps = (_ctx) => {
    open Recipe;
    // Clear our recipe from any undefined values before returning
    let props = toJson(myRecipe)->stripUndefined->fromJson;
    {
      "props": props
    }
};
Enter fullscreen mode Exit fullscreen mode

That's it! After compiling and rerunning the code, the error is gone and our app works as expected.

Note: If you are handling an array(recipe), you can apply the same technique to the whole array in an Js.Json.t as well, since stringify / parse can act on json objects and json arrays.

It's also noteworthy that the last fromJson call is not needed when we are not enforcing any type shapes for the props value. In my typical ReScript / Next projects (see my rescript-nextjs-template ReScript template) I enforce the props type across getStaticProps and my React components.

(As long as you are making sure that Recipe.t contains JSON compliant values, it's also perfectly safe to continue using the modified record returned by toJson(myRecipe)->stripUndefined->fromJson in ReScript code, since all runtime operations on an option value will continue to work as intended.)

Conclusion

We highlighted the problems with NextJS' getStaticProps props value limitations and why this is relevant for writing idiomatic ReScript code that interops well within the framework.

We showed how we can tackle the JSON serialization problem, either by converting option to Nullable.t, or do unsafe undefined value stripping using JSON.stringify / JSON.parse. We also dabbled a little bit into the world of compile type / runtime value representation of ReScript records.

If you are interested in more ReSCript related content, make sure to follow me on Twitter and stay tuned for more practical insights!

💖 💪 🙅 🚩
ryyppy
Patrick Ecker

Posted on May 15, 2021

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

Sign up to receive the latest update from our blog.

Related