ReScript records, NextJS, undefined and getStaticProps
Patrick Ecker
Posted on May 15, 2021
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
}
}
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
}
};
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.
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 toundefined
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
};
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/>;
};
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" }
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>
};
}
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
};
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);
};
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
}
};
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!
Posted on May 15, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.