Introduction

This is one of several articles on Rescript, a functional programming language in the Javascript ecosystem that I’ve quickly come to love.

In prior articles, I introduced the steps to build a simple single page app in React. In this one, I start a new project to build a basic express server using the bs-express library.

Patreon

This series takes a lot of time to write and maintain. I started it while I have some free time between jobs, but I’ll probably need some extra motivation to keep working on it once I’m busy with work again.

It’s become popular enough that I’ve already fielded a few change requests that have taken additional time. This has slowed me down on new content, but it makes the existing content a lot better.

I’m not one for begging for money on the Internet, but if you’re interested in providing some of that motivation, I’ve set up a Patreon account. I thank you for your support.

As a bonus for patrons, articles will be published there at least two weeks before they make it to my public blog.

Table of Contents

Should you even be using Rescript and nodejs?

This question isn’t as odd as it seems. Rescript is a beautiful gateway to the JS ecosystem, and node is an integral part of that same JS ecosystem.

But Rescript’s sibling language, Reason compiles to native code. Rescript was originally identical to Reason syntax, but they are diverging to make the language more ergonomic for JS developers.

Reason, however, ties into the OCAML ecosystem the same way Rescript does with JS. As an example, you could use the native-compiled Opium library instead of express. Indeed, I am hoping to write an article doing just that (for comparison) sometime.

Most people using Rescript, however, are not choosing it in a vacuum. Perhaps you have a Javascript nodejs app already, or you have other Javascript infrastructure. Adding Rescript to your Javascript code can only make it safer.

Let’s begin

In contrast to my recent articles, we will be starting a new app instead of extending the previous one. We aren’t doing any react this time, but we will be pulling in some of the reducer logic from the react project a bit later.

If you don’t already have the rescript toolchain installed:

npm install -g bs-platform

Next, initialize a new rescript project with the node theme:

bsb -init rescript-express-recipes -theme node
cd rescript-express-recipes

Run npm install --save-dev bs-platform. I know you installed it globally, but you need a local version as well.

Sanity check that it will build with npm run build.

Remove src/demo.ml. The default node theme assumes you are building in the OCAML ecosystem. Yes, bsb can compile three languages to Javascript: OCAML, Reason, and Rescript!

Create src/Index.res as follows:

Js.log("Hello, Rescript")

Edit package.json and add a new start script to the scripts array:

"scripts": {
  "clean": "bsb -clean-world",
  "build": "bsb -make-world",
  "watch": "bsb -make-world -w",
  "start": "node src/Index.bs.js"
},

(You may want to add Nodemon to have auto-restarting.) Check that npm install, npm build, and npm start do what you’d expect.

This is what I think the initial state of the repo should be when you originally run bsb-init -theme node. I’ve created a git branch to clone from for next time I want to make a new node-rescript app.

Adding dependency on bs-express

The bs-express library provides rescript bindings to the express library. The release available on npm has not been ported to rescript, so you’ll want to install off master as follows:

npm install https://github.com/reasonml-community/bs-express.git

Don’t forget to add "bs-express" to the bs-dependencies array in bsconfig.json. You don’t need to reference the git branch; just the name in quotes "bs-express". Rescript looks in node_modules for bs-express, so it automatically finds the custom repo and branch.

Hello, express

Let’s start by porting the Express hello world example to Rescript:

In javascript, this is:

const express = require("express");
const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("Hello World!");
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

The lazy way to port this is to copy that code into a new Index.res file and place it between %raw tags. However, I’m just going to skip to the functional rescript code with a detailed explanation to follow:

open Express

let app = express()
let port = 3000

App.get(
  app,
  ~path="/",
  Middleware.from((_, _, res) => {
    res->Response.status(Response.StatusCode.Ok)->Response.sendString("Hello World")
  }),
)

let server = App.listen(
  app,
  ~port,
  ~onListen=_ => {
    Js.log(`Example app listening at http://localhost:${Js.Int.toString(port)}`)
  },
  (),
)

We use open Express to merge the entire express namespace into ours. Another option would have been to use Express.App and Express.Middleware everywhere. Or, if you’re using the recently released (at time of writing) 9.0.2 release of rescript, you can pull in just the names you want using pattern match over modules syntax.

Rescript doesn’t have “methods” on “objects”. Instead, we pass app as the first arg to App.get and App.listen. Pipe syntax could also be used as in app->App.get(~path = "/".... I felt the function call syntax made more sense in this environment since it’s not creating a transformation on the app.

The path passed into App.get is passed as a keyword argument (prefixed by a ~). I think this is because there are multiple ways to set a route in express, and path is only one of them.

Like App.get, Response.status and Response.sendString are module functions where their first parameter is a Response.t type (e.g: res).

Most of the Response methods return another Response.t, which is great for chaining with pipes. In this case we set the status code to 200 OK, then send a string into the stream. sendString returns an Express.complete. This return value provides a sort of compile-time type safety. It prevents you from accidentally trying to do something with the response after it has been sent.

Note that unlike Javascript App.get accepts a Middleware instead of an anonymous function. This is largely to provide a pile of type safety that isn’t available in Javascript. We just pass the anonymous function into Middleware.from. The function accepts three arguments, but we ignore the first two using _ because, for this particular endpoint, we’re only interested in the Response.t object, res.

App.listen accepts the app itself as the first argument and port and onListen are passed as keyword arguments. One neat thing is that ~port automatically references the module-level port variable. There’s no need to write ~port=port.

The handler passed as onListen always receives an argument, which can be null, undefined, or Js.Exn. The latter could be raised if it was not possible to establish a connection. Exceptions in Rescript are their own special topic - Hopefully I’ll have time to cover them in a later article. For now, we just ignore the argument by naming it _. This is throwing away the compile-time safety Rescript is giving us, but I’m not worried about it for now.

Note that the last argument of App.listen is an empty (). Rescript demands that there always be one non-optional argument after any keyword arguments, “for the sake of the type system” to quote the docs.

I don’t actually know why this is necessary, but if this is one of the things that makes the Rescript compiler so blazing fast, I’ll accept it!

Now you can test it! Run npm run watch in one terminal window (10 ms to compile. NICE!) and npm start in another terminal.

Access localhost:3000 to see your rescript express app in action.

Overview of sample app

Now that we have express working, let’s add some real routes. We’ll extend the Recipes idea from the previous articles. Instead of a react app, this time we’ll be making an API to store recipes.

For now it will just store the recipes in memory, using similar logic to what we saw in our react reducers.

The API is straightforward:

  • POST a new recipe with fields id, title, ingredients, and instructions.
  • POST to add a tag to a recipe with fields recipeId and tag.
  • GET all tags
  • GET all ids and titles for a given tag
  • GET the fields for an individual recipe for a given ID

Application state

Since we’ve already done in-memory state management for the React app, I’m going to start by copying the Store.res file from there. That store will work (almost) perfectly here, too! We don’t need some sort of “server-side redux” because the whole idea of “redux” is baked into the Rescript language.

I’m not going to go into the full details since it’s covered in the previous article. The file is copied below if you need it with a couple modifications.

I want to make one change at this time because of a mistake I made in my data design: Recipes don’t have an id field. Let’s add a new id field with an integer type.

This change will require:

  • a third top-level state for nextId
  • a modification to add an id field to the recipe type
  • change the recipes map to Map.Int.t to index off the id
  • change the tags map to Map.String.t<array<int>> instead of Map.String.t<array<string>> because tags now point to integers.

If you make those four changes, you’ll get a sequence of obvious compiler errors that are straightforward to fix. Once it compiles, you can be quite confident the refactor is complete.

If you get stuck or don’t feel like doing it, here’s the whole file with these changes (You may also want to look up the relevant git commit in the repo to see how the file has changed):

open Belt

type id = int

type recipe = {
  id: id,
  title: string,
  ingredients: string,
  instructions: string,
  tags: array<string>,
}

type state = {
  nextId: id,
  recipes: Map.String.t<recipe>,
  tags: Map.String.t<array<string>>,
}

let initialState: state = {
  nextId: 0,
  recipes: Map.String.empty,
  tags: Map.String.empty,
}

type action =
  | AddRecipe({title: string, ingredients: string, instructions: string})
  | AddTag({recipeTitle: string, tag: string})

let addRecipe = (state: state, title: string, ingredients: string, instructions: string) => {
  {
    recipes: state.recipes->Map.String.set(
      title,
      {
        id: state.nextId,
        title: title,
        ingredients: ingredients,
        instructions: instructions,
        tags: [],
      },
    ),
    nextId: state.nextId + 1,
    tags: state.tags,
  }
}

let updateTagsArray = (taggedRecipesOption: option<array<string>>, recipeTitle: string) => {
  switch taggedRecipesOption {
  | None => Some([recipeTitle])
  | Some(taggedRecipes) => Some(taggedRecipes->Array.concat([recipeTitle]))
  }
}

let addTag = (state: state, recipeTitle: string, tag: string) => {
  let recipeOption = state.recipes->Map.String.get(recipeTitle)

  switch recipeOption {
  | None => state
  | Some(recipe) => {
      let recipeTags = recipe.tags->Array.concat([tag])
      let recipes = state.recipes->Map.String.set(recipe.title, {...recipe, tags: recipeTags})

      let tags =
        state.tags->Map.String.update(tag, taggedRecipesOption =>
          updateTagsArray(taggedRecipesOption, recipe.title)
        )

      {
        nextId: state.nextId,
        recipes: recipes,
        tags: tags,
      }
    }
  }
}

let reducer = (state: state, action: action) => {
  switch action {
  | AddRecipe({title, ingredients, instructions}) =>
    addRecipe(state, title, ingredients, instructions)
  | AddTag({recipeTitle, tag}) => addTag(state, recipeTitle, tag)
  }
}

UseReducer

The react implementation was able to manage state using react’s UseReducer. We aren’t using react here, but we can implement a custom version of UseReducer to store the current global state. Happily, this will introduce a few new Rescript concepts.

Let’s first define the interface for our reducer; it’ll be the same as for react’s UseReducer. An interface for a module is declared using module type name = {...}. Note the type, in contrast to module name = {...}, which is for the implementation.

As an aside, you can also declare an interface for a module that is declared in a file by putting the interface in another file named ModuleName.resi

Add the following interface at the end of Store.res:

module type UseReducer = {
  let getState: unit => state
  let dispatch: action => unit
}

Here’s the implementation:

module Reducer: UseReducer = {
  let currentState = ref(initialState)
  let getState = () => currentState.contents
  let dispatch = (action: action) => {
    currentState.contents = reducer(currentState.contents, action)
  }
}

This uses some Rescript features you may not be familiar with yet. First note that the type of the module is the UseReducer we just defined. This is how you “implement” an interface.

We are using the ref to create a mutable binding. If you’re familiar with refs in React, it’s a similar concept. For the most part, mutable state, even with refs is strongly discouraged in functional programming. In this case, global state that is modified by functional elements is a reasonable place to use them.

The contents of the referred-to object can be accessed using <name>.contents. You can set them the same way.The function implementations for getState and dispatch follow naturally to fulfill the UseReducer interface.

Returning JSON

JSON in Rescript is a little messy.

Let me rephrase that. JSON in strongly typed languages is always messy. JSON isn’t meant to be strongly typed, which is why things like JSONSchema and GraphQL have evolved.

Express has a middleware that returns JSON. It can be enabled easily with App.use(app, Middleware.json()). Then we can modify our default / route to build and return a JSON value:

open Express

let app = express()
let port = 3000

App.use(app, Middleware.json())

App.get(
  app,
  ~path="/",
  Middleware.from((_, _, res) => {
    let result = Js.Dict.empty()
    result->Js.Dict.set("Hello", "World"->Js.Json.string)
    let json = result->Js.Json.object_
    res->Response.status(Response.StatusCode.Ok)->Response.sendJson(json)
  }),
)

let server = App.listen(
  app,
  ~port,
  ~onListen=_ => {
    Js.log(`Example app listening at http://localhost:${Js.Int.toString(port)}`)
  },
  (),
)

The key thing to note is that we build the result in a Js.Dict. This doesn’t work like the Belt.MapString we used in the reducer and earlier articles. Belt.map is immutable, where Js.Dict is mutable. We construct a unique dict with a Js.Dict.empty() function call in contrast with Belt.MapString.empty, which is a single value.

The values are Js.Json.string objects created by piping a normal string into the Js.Json.string function. It’s similar to the React.string behaviour we saw in the React app.

The resulting Js.Dict gets sent into Js.Json.object_, which tells Rescript that the dict can be used as a json object. Like Js.Json.string, it’s another typing guarantee/demand.

Finally, the resulting Json object is passed to Response.sendJson with the response.

Now you can rebuild/restart the server and access http://localhost:3000 to see your new JSON object.

Receiving JSON

Getting back to our actual project, let’s add the addRecipe endpoint that needs to receive a JSON object. We’ll have to translate untyped and unreliable user data into a recipe. That means checking if we have valid JSON and checking that all the required fields exist.

Admittedly, this is stuff I don’t usually bother with when coding the early phases of a Javascript project, which just goes to show that my code is usually broken!

Here’s the code, with some discussion to follow:

App.post(
  app,
  ~path="/addRecipe",
  Middleware.from((_next, req, res) => {
    let jsonResponse = Js.Dict.empty()
    switch req->Request.bodyJSON {
    | None => jsonResponse->Js.Dict.set("error", "not a json request"->Js.Json.string)
    | Some(_json) => jsonResponse->Js.Dict.set("good", "response"->Js.Json.string)
    }

    res->Response.sendJson(jsonResponse->Js.Json.object_)
  }),
)

Request.bodyJSON() provides access to the value that was inserted into the body by the JSON middleware. It accepts only one parameter, a Request.t object that we send into it by pipe. It returns an option indicating whether the request is valid JSON. We just switch on that value to return a different result depending on the validity.

I test this using Advanced rest client, but feel free to use whatever tool you like, or to mess around with CURL parameters instead.

Now we have to convert that json to a recipe, requiring a messy bit of parsing. There is a battle-tested third-party library called bs-json that can make this somewhat more orderly. However, it is not optimized for Rescript’s pipe-first syntax. There is also a new kid on the block called jzon that may be of interest. I haven’t tried either, yet, as learning to do it manually will prove most educational.

Warning: this is going to take a few attempts before it is very readable! I’m learning with you, here.

Here’s my first attempt, replacing the |Some(_json) line with something that actually parses the json:

| Some(jsonBody) =>
  switch (
    jsonBody->Js.Dict.get("title")->Belt.Option.map(r => Js.Json.decodeString(r)),
    jsonBody->Js.Dict.get("ingredients")->Belt.Option.map(r => Js.Json.decodeString(r)),
    jsonBody->Js.Dict.get("instructions")->Belt.Option.map(r => Js.Json.decodeString(r)),
  ) {
  | (Some(Some(title)), Some(Some(ingredients)), Some(Some(instructions))) =>
    jsonResponse->Js.Dict.set("good", title->Js.Json.string)
    jsonResponse->Js.Dict.set("with", ingredients->Js.Json.string)
    jsonResponse->Js.Dict.set("attributes", instructions->Js.Json.string)
  | _ => jsonResponse->Js.Dict.set("error", "missing attribute"->Js.Json.string)
  }
}

Belt.Option.map is a sort of “option translator”. If the input option is None it returns None, but if it is Some(x) the return is a new Some() where the value is what you get if you pass x into the provided function.

Note that there is also a Js.Option.map function that does a similar thing, but it’s not optimized for pipe-first behaviour preferred by Rescript; the option is passed as the second arg instead of the first. You’d have to use |> instead, but Rescript discourages use of that operator.

For each of the three types, we pass it into Js.Json.decodeString, which returns an option with the requested value if it was a string, or None if it was not. Unfortunately, the result of this map call is to end up with an option containing an option (we’ll fix this later).

The three resulting options are collected into a single tuple, which can be pattern matched all at once. This is pretty cool; we are only interested in two cases: all the attributes are both present and the correct type, or they are not. If so, we destructure the whole thing and craft an arbitrary JSON response indicating it was successful (We’ll replace this with real logic later). For all other patterns, whether because the JSON couldn’t be decoded or an attribute couldn’t be found, we return an error.

Making better use pipelines

With three nested switches, the current implementation is horribly messy. It’s tricky to read and therefore tricky to maintain. We can replace all but one of the switch statements with a more readable pipeline.

The primary tool we’ll need is Belt.option.flatMap. It’s exactly the same as Belt.option.map except the function passed into it needs to return an option. flatMap will then extract the value from that option (if it is a Some) and return a single option. In other words, it “flattens” the Some(Some(value)) into Some(value).

We can use map, flatMap, and a bit of work to transform the endpoint into:

App.post(
app,
~path="/addRecipe",
Middleware.from((_next, req, res) => {
  let jsonResponse = Js.Dict.empty()
  let jsonFields =
    req
    ->Request.bodyJSON
    ->Belt.Option.flatMap(Js.Json.decodeObject)
    ->Belt.Option.map(jsonBody => (
      jsonBody->Js.Dict.get("title")->Belt.Option.flatMap(Js.Json.decodeString),
      jsonBody->Js.Dict.get("ingredients")->Belt.Option.flatMap(Js.Json.decodeString),
      jsonBody->Js.Dict.get("instructions")->Belt.Option.flatMap(Js.Json.decodeString),
    ))

  switch jsonFields {
  | Some(Some(title), Some(ingredients), Some(instructions)) =>
    jsonResponse->Js.Dict.set("good", title->Js.Json.string)
    jsonResponse->Js.Dict.set("with", ingredients->Js.Json.string)
    jsonResponse->Js.Dict.set("attributes", instructions->Js.Json.string)
  | _ => jsonResponse->Js.Dict.set("error", "missing attribute"->Js.Json.string)
  }

  res->Response.sendJson(jsonResponse->Js.Json.object_)
}),
)

I quite like this. The pipes all point in the same direction and line up, and we don’t have any extraneous options. I find it very readable. True, it is awfully clumsy compared to raw JS access, but for the type safety guarantees, I don’t mind.

However, (and there’s a good reason I didn’t mention this sooner ;-)) if you do want something like quick and dirty raw JS access, you can just bind a function to JSON.parse and use Object syntax to look up arbitrary attributes on the object. See an example here.

Updating state

Instead of returning the json we received, we want to update our state. A call to Store.Reducer can be used to dispatch the action. Then the returned json just needs to return the ID of the new element.

Replace the switch jsonFields arm that contains three jsonBody->Js.Dict.set.... with:

  | Some(Some(title), Some(ingredients), Some(instructions)) => {
      open Store.Reducer
      let state = getState()
      let id = state.nextId
      dispatch(AddRecipe({title: title, ingredients: ingredients, instructions: instructions}))
      jsonResponse->Js.Dict.set("id", id->Js.Int.toFloat->Js.Json.number)
    }

We first open Store.Reducer to bring getState and dispatch into our namespace. getState gives us access to the global state so we can grab nextId for the return value. dispatch allows us to send an action into the reducer.

One annoying bit is the need to convert the int to a float to a number in order to set the ID on the response value. The pipeline syntax makes this pretty readable, but it still feels like unnecessary overhead. However, it compiles to exactly the same Javascript you would have written by hand: jsonResponse["id"] = id;. It’s enforcing type safety without sacrificing runtime speed.

Use your favourite REST client to POST valid json to the endpoint several times. You should see the returned id incremented with each POST.

Getting state

Next, let’s add an endpoint to GET a current recipe by ID. First, we need some boilerplate to add a new route and get a param from it:

App.get(
  app,
  ~path="/recipes/:id",
  Middleware.from((_next, req, res) => {
    open Belt
    let jsonResponse = Js.Dict.empty()
    let state = Store.Reducer.getState()

    let idOption =
      req
      ->Request.params
      ->Js.Dict.get("id")
      ->Option.flatMap(Js.Json.decodeString)
      ->Option.flatMap(Int.fromString)

    switch idOption {
    | Some(id) => jsonResponse->Js.Dict.set("id", id->Float.fromInt->Js.Json.number)
    | None => jsonResponse->Js.Dict.set("error", "id must be numerical value"->Js.Json.string)
    }
    res->Response.sendJson(jsonResponse->Js.Json.object_)
  }),
)

This is all boilerplate, and I don’t love it when there are this many lines of boilerplate. The main new feature is the :id in the path. This is express syntax for matching in a route.

Then we have a multi-step pipeline to extract an integer from that route:

- We pipe req into `Request.params`, which returns a dictionary of json
  values
- We pipe that dict into `Js.Dict.get` to extract the value with a key of
  `id`
- We pipe the extracted param into `Option.flatmap` to decode a
  `Option<string>` from the json
- We pipe the string option into another flat map to convert it to an
  integer with the `Int.fromString` function

At this point, idOption is guaranteed to be None or contain an integer value. We switch on that option to either:

  • Set an error on the response
  • Set a value with the recipe`s ID

Try accessing the endpoint in your browser (It’s a GET request) or REST client:

Returning the recipe with the state

Of course, we want to return the entire recipe, not just the ID. This requires two steps:

  • check if the ID points to a valid recipe
  • Look up the recipe on the state and convert the values to JSON

My first attempt looks like this:

  switch idOption {
  | Some(id) => {
      let recipe = state.recipes->Map.Int.get(id)
      switch recipe {
      | None => jsonResponse->Js.Dict.set("error", "no such recipe"->Js.Json.string)
      | Some(recipe) => {
          jsonResponse->Js.Dict.set("id", id->Float.fromInt->Js.Json.number)
          jsonResponse->Js.Dict.set("title", recipe.title->Js.Json.string)
          jsonResponse->Js.Dict.set("ingredients", recipe.ingredients->Js.Json.string)
          jsonResponse->Js.Dict.set("instructions", recipe.instructions->Js.Json.string)
        }
      }
    }
  | None => jsonResponse->Js.Dict.set("error", "id must be numerical value"->Js.Json.string)
  }

There’s no new information here, but I don’t like this code. I had such a nice pipeline going, but then I had to swich to switches (that’s such a pretty turn of phrase…) because I couldn’t pipe the idOption into Map.Int.get. The first argument of Map.Int.get has to be the state.recipes map, not an ID.

To solve this, I started to toy around with pipe-last syntax, but eventually realized I didn’t need it. I just need to return the right value from one extra flatMap on the recipe itself:

App.get(
  app,
  ~path="/recipes/:id",
  Middleware.from((_next, req, res) => {
    open Belt
    let jsonResponse = Js.Dict.empty()
    let state = Store.Reducer.getState()
    let recipeOption =
      req
      ->Request.params
      ->Js.Dict.get("id")
      ->Option.flatMap(Js.Json.decodeString)
      ->Option.flatMap(Int.fromString)
      ->Option.flatMap(id => state.recipes->Map.Int.get(id))

    switch recipeOption {
    | None => jsonResponse->Js.Dict.set("error", "unable to find that recipe"->Js.Json.string)
    | Some(recipe) => {
        jsonResponse->Js.Dict.set("id", recipe.id->Float.fromInt->Js.Json.number)
        jsonResponse->Js.Dict.set("title", recipe.title->Js.Json.string)
        jsonResponse->Js.Dict.set("ingredients", recipe.ingredients->Js.Json.string)
        jsonResponse->Js.Dict.set("instructions", recipe.instructions->Js.Json.string)
      }
    }
    res->Response.sendJson(jsonResponse->Js.Json.object_)
  }),
)

Now, this is what functional programming should look like! One long sequence of pipes where each step returns either None or a transformation of the value. Just one switch statement at the end to choose the correct logic.

You might call this a pipe dream.

  • You can test this in your REST client:

    • use /addRecipe to add a couple recipes
    • use e.g. /recipes/1 to retrieve one of them

Adding tags

A post request for addTagToRecipe can follow the same logic we’ve already seen:

  • extract the necessary values from json: an int recipeId and a string tag
  • look up the recipe in the state
  • if the json was correcct and is a valid recipe, invoke the AddTag action
  • Set appropriate JSON fields for success and error paths
  • sendJson the response

It’s not that complicated when I write it out like that, but it’s rather verbose in code:

App.post(
  app,
  ~path="/addTagToRecipe",
  Middleware.from((_next, req, res) => {
    open Belt
    open Store.Reducer
    let jsonResponse = Js.Dict.empty()

    let jsonFields =
      req
      ->Request.bodyJSON
      ->Option.flatMap(Js.Json.decodeObject)
      ->Option.map(jsonBody => (
        jsonBody
        ->Js.Dict.get("recipeId")
        ->Option.flatMap(Js.Json.decodeNumber)
        ->Option.map(Int.fromFloat)
        ->Option.flatMap(id => getState().recipes->Map.Int.get(id)),
        jsonBody->Js.Dict.get("tag")->Option.flatMap(Js.Json.decodeString),
      ))

    switch jsonFields {
    | Some(Some(recipe), Some(tag)) => {
        jsonResponse->Js.Dict.set("success", true->Js.Json.boolean)
        dispatch(AddTag({recipeId: recipe.id, tag: tag}))
      }
    | _ => jsonResponse->Js.Dict.set("error", "invalid request"->Js.Json.string)
    }
    res->Response.sendJson(jsonResponse->Js.Json.object_)
  }),
)

I don’t really like the verbosity, but that’s kind of the price of type safety in JSON. The equivalent Javascript code, if it’s really correctly checking that all the fields have values and are the right type, would actually be uglier, and it would be difficult to have the confidence that you checked all the edge cases.

Additionally, you can use one of the third-party json libraries I mentioned earlier to define schemas that would make this code look a little more readable.

Endpoint to get the list of tags

This is a straightforward endpoint that just looks up the keys in the tags state:

App.get(
  app,
  ~path="/allTags",
  Middleware.from((_next, _req, res) => {
    let jsonResponse = Js.Dict.empty()
    jsonResponse->Js.Dict.set(
      "tags",
      Store.Reducer.getState().tags->Belt.Map.String.keysToArray->Js.Json.stringArray,
    )
    res->Response.sendJson(jsonResponse->Js.Json.object_)
  }),

The two new functions are keysToArray, which does what you expect, and stringArray, which, I hope, also does what you expect.

Endpoint to get all recipes for a tag

This one is a bit more interesting!

Let’s get our goal in mind first; we need to return an array of objects like this:

[{recipeId: 1, title: "potatoes", recipeId: 2, title: "leaves"}]

This is a multi-step operation. First we need to get the tagged recipes that the user requested:

App.get(
  app,
  ~path="/tags/:tag",
  Middleware.from((_next, req, res) => {
    open Belt
    let jsonResponse = Js.Dict.empty()
    let state = Store.Reducer.getState()
    let taggedRecipesOption =
      req
      ->Request.params
      ->Js.Dict.get("tag")
      ->Option.flatMap(Js.Json.decodeString)
      ->Option.flatMap(tag => state.tags->Map.String.get(tag))
  })
)

This is pretty straightforward, in the sense that the pipes keep going straight… and forward.

At the end of this, taggedRecipesOption contains either:

  • None (of course)
  • A Some() containing an array of integer recipe ids associated with that tag

Then we can switch on that option to build the json return value:

switch taggedRecipesOption {
| None => jsonResponse->Js.Dict.set("error", "tag not found"->Js.Json.string)
| Some(recipeIds) => {
    let recipes =
      recipeIds
      ->Array.map(id => {
        state.recipes
        ->Map.Int.get(id)
        ->Option.map(recipe => {
          let dict = Js.Dict.empty()
          dict->Js.Dict.set("id", id->Js.Int.toFloat->Js.Json.number)
          dict->Js.Dict.set("title", recipe.title->Js.Json.string)
          dict
        })
      })
      ->Array.keep(value => value->Option.isSome)
      ->Array.map(opt => opt->Option.getUnsafe->Js.Json.object_)
      ->Js.Json.array
    jsonResponse->Js.Dict.set("recipes", recipes)
  }
}
res->Response.sendJson(jsonResponse->Js.Json.object_)

This one’s a bit scary because it contained a nested pipeline. The recipeIds get piped into Array.map, which applies a function to each of the values in the array. In our case, that function is a second pipeline that:

  • gets the recipe option
  • if it exists, construct a json object with the title and id

Once this has been done for all keys, the pipeline is an array<Option<{id, title}>>.

Then, we need to extract the options, which is a two step operation. First, we pipe the whole array into Array.keep. That function is essentially the same as filter in Javascript. It applies a filtering function that returns a boolean to each value in the array. In this case, we only keep those values that are Some and drop all the Nones.

Now we have an array<Some({id, title])>. Since we know all the options in the array are Some options, we can pipe it into another map that applies getUnsafe. As its name suggests, this is not a safe operation in that it will explode if your option is None. But in this case, we can be certain it actually is safe because we proactively removed the Nones.

Think twice before using getUnsafe; in this case, I did think twice and decided it’s ok.

Continuing the pipeline, we convert the array of json objects to a json array type. This value can then be set on the JS dictionary, and the resulting dict can be converted to a json object and returned.

Conclusion

That’s all for today. I encourage you to play around with the four endpoints above using a REST client. You should be able to add and tag recipes, and retrieve individual recipes, all the tags, and the recipes for a given tag. But that’s not all you should try. You should also be able to send invalid data into all of those endpoints and get some sort of error message (rather than a server crash or 500) in response.

I am very confident that this code has fewer bugs than equivalent Javascript code, even though I haven’t written a line of testing code yet. I’m equally confident that it has fewer bugs than equivalent Typescript code. Typescript is a strongly typed language, but it is not soundly typed.

I want to talk about the legibility of this code a bit. As a block of code, it’s rather dense and contains a lot of function calls. The key is to read it one line at a time. Each line is a discrete logical operation. In standard javascript code, you would probably need three or four lines for several of these steps and would separate the blocks of three or four with blank lines.

My eyes feel intimidated when they see a solid block of code like above. However, when I actually force them to peek into the pipeline, it flows very naturally. In a production app, I might split some of these bits out to their own functions so the pipeline isn’t as long. There are also parts of it that could be more reusable with an express middleware. And of coures, using a proper json encoding library would probably help as well.

My favourite thing about Rescript continues to be the compile speed, but the guaranteed type safety net is great as well.

It was nice that the bs-express library already exists so we didn’t have to write our own JS bindings. Writing our own bindings is very easy (I have a couple articles in the queue already), but easing in using a third-party library is pretty convenient.

By the way, bs-express is looking for a new maintainer. Even if you aren’t willing to take over the maintenance, the current maintainer has agreed to cut a release if you take the time to fix up the github pipeline.

This branch has the final state of the project including commits for all the sections you’ve seen so far.