Using Rescript with NodeJs and Express
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.
Other articles in this series
With over a dozen articles and counting, I’ve created a table of contents listing all the articles in this series in reading order.
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
, andinstructions
. - POST to add a tag to a recipe with fields
recipeId
andtag
. - 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 therecipe
type - change the
recipes
map toMap.Int.t
to index off the id - change the
tags
map toMap.String.t<array<int>>
instead ofMap.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:
- http://localhost:3000/recipes/5 should
return
{id: 5}
- http://localhost:3000/recipes/a should
return
{error: "id must be numerical value"}
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
- use
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 stringtag
- 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 None
s.
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 None
s.
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.