Quickly refactoring with Rescript
Introduction
This is part of an ongoing series about the Rescript programming language. I’m taking notes as I build a toy “Recipe Book” app to explore the Rescript ecosystem.
As a continuation of my previous article on building a graphql server in Rescript. Before we can actually create the graphql endpoints for our server, I discovered we need to refactor the backend code quite a bit. The data store in the frontend has evolved from what I originally planned, and the backend hasn’t kept up.
This is a terrific opportunity to see how Rescript’s lightning fast compile times make it easy to quickly and confidently make sweeping code changes.
Patreon
This series takes a lot of time to write and maintain. I would definitely have given it up by now if not for the motivation provided by my patrons. If you would like to continue reading content like this, I’ve set up a Patreon account. I thank you for your support. I am not expecting this to pay for my time investment, but it means so much to know people are placing value on my content.
As a bonus for patrons, articles will be published there at least two weeks before they make it to my public blog. In addition, if enough interest is shown through Patreon, I will consider writing a book on Rescript.
While I originally set up the Patreon for individuals to support me, I’d like to point out that if your company is invested in the Rescript ecosystem, my work is probably helping you recruit and onboard new hires. Supporting my efforts makes the ecosystem stronger.
Other ways to show support include sharing the articles on social media, commenting, or a quick thank you on the Rescript forum.
Other articles in 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.
Let’s begin
We’ll be extending the rescript-express-recipes repo I’ve used in a couple previous articles.
If you haven’t been following along with these articles, you can git checkout
the
graphql
branch.
If you want to keep up with the commits for this article, they are in the refactor branch. Each commit roughly maps to a section in this article.
Note: This article in particular contains a lot of modification instructions that may be easier to follow in github’s diff format than in my inline code examples.
Update store for rxdb sync
Our end goal is to supply mutations for the frontend app in the rescript-offline repository. As a reminder, that project uses rxdb with two collections defined as follows:
type id = string
type title = string
type ingredients = string
type instructions = string
type tag = string
type recipe = {
id: id,
title: title,
ingredients: ingredients,
instructions: instructions,
tags: array<tag>,
}
type taggedRecipes = {
tag: tag,
recipes: array<id>,
}
Our server code, on the other hand, currently defines this state:
type id = int
type recipe = {
id: id,
title: string,
ingredients: string,
instructions: string,
tags: array<string>,
}
type state = {
nextId: id,
recipes: Map.Int.t<recipe>,
tags: Map.String.t<array<int>>,
}
This is the kind of inconsistency that happens when your frontend and backend developers don’t communicate effectively! (If you think I should have communicated better with myself, you don’t know me very well!) The main functional difference is that the id in the offline app is a string, but our sample graphql server is using an int. But I think it’s worth porting the additional type safety into the backend as well.
So go ahead and merge the above two sequences of code in Store.res
. It should
come out as follows:
type id = string
type title = string
type ingredients = string
type instructions = string
type tag = string
type recipe = {
id: id,
title: title,
ingredients: ingredients,
instructions: instructions,
tags: array<tag>,
}
type state = {
recipes: Map.String.t<recipe>,
tags: Map.String.t<array<id>>,
}
let initialState: state = {
recipes: Map.String.empty,
tags: Map.String.empty,
}
String ids
The ids being strings instead of ints means that I had to remove nextId
.
Instead, the offline app uses a uuid
. The above change breaks the build in
addRecipe
because it’s still trying to create an integer. For symmetry
with the offline app, add a dependency on UUID:
npm install --save uuid
Then add a binding to the library at the top of Store.res
:
@module("uuid") external uuid: unit => string = "v4"
I just copy-pasted this binding from the other project. Theoretically, I could
extract this one line of code to a package. In fact, there’s also a
reason-uuid
library that I could depend on. But Rescript bindings are so easy
that I’ve been finding it far more productive to write them on demand than
depend on external packages.
Now we just need to follow the Rescript errors. Gotta love those lightning fast compile times. It only took me a minute to identify and fix the first set of changes:
- Change
let id = state.nextid
tolet id = uuid()
- Remove the
nextId: state.nextId + 1
in the reducer itself - Change the
Map.Int
references toMap.String
in:- The new state setting in
addRecipe
- The
recipeOption
lookup inaddTag
- The recipe state update in
addTag
- The new state setting in
- Change all the type definitions of
recipeid
fromint
toid
. (A find and replace onint
will suffice if you don’t want to track them down) - While I was at it, I replaced all the
string
definitions with the more concretetitle
,ingredients
,instructions
, andtag
types as appropriate. - Remove the
nextId:
from the state update inaddTag
Unfortunately, I got slowed up with the next build error. It is in Index.res
,
which needs to know what the new ID is so it can return it. The solution is to
modify the AddRecipe
action and associated addRecipe
reducer to accept the
id passed in as an argument instead of generating a new one:
type action =
| AddRecipe({id: id, title: title, ingredients: ingredients, instructions: instructions})
| AddTag({recipeId: id, tag: tag})
let addRecipe = (
state: state,
id: id,
title: title,
ingredients: ingredients,
instructions: instructions,
) => {
{
recipes: state.recipes->Map.String.set(
id,
{
id: id,
title: title,
ingredients: ingredients,
instructions: instructions,
tags: [],
},
),
tags: state.tags,
}
}
You’ll also need to update the reducer
to include the id in both the
AddRecipe
pattern match and the addRecipe
function call:
let reducer = (state: state, action: action) => {
switch action {
| AddRecipe({id, title, ingredients, instructions}) =>
addRecipe(state, id, title, ingredients, instructions)
| AddTag({recipeId, tag}) => addTag(state, recipeId, tag)
}
}
Now we can update the addRecipe
endpoint in Index.res
to generate its own
uuid before returning it as a json string instead of a number:
switch jsonFields {
| Some(Some(title), Some(ingredients), Some(instructions)) => {
open Store.Reducer
let id = Store.uuid()
dispatch(
AddRecipe({id: id, title: title, ingredients: ingredients, instructions: instructions}),
)
jsonResponse->Js.Dict.set("id", id->Js.Json.string)
}
addTagToRecipe
also needs to be updated to accept a string instead of an int.
Both the number decoder (inside the flatMap
) and the Map.Int
need to be
changed to strings:
jsonBody
->Js.Dict.get("recipeId")
->Option.flatMap(Js.Json.decodeString)
->Option.flatMap(id => getState().recipes->Map.String.get(id)),
jsonBody->Js.Dict.get("tag")->Option.flatMap(Js.Json.decodeString),
The /recipes/:id
endpoint requires similar changes:
- Remove the
flatMap
that converts the id string to an int - Look up the id using
Map.String.get
instead ofMap.Int.get
- Set the
id
on the response using aJs.Json.string
instead ofJs.Json.number
After completing all of the above, the whole endpoint looks like this:
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(id => state.recipes->Map.String.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->Js.Json.string)
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)
jsonResponse->Js.Dict.set("tags", recipe.tags->Js.Json.stringArray)
}
}
res->Response.sendJson(jsonResponse->Js.Json.object_)
}),
)
I won’t show the code, but you’ll have to do the same thing in the /tags/:tag
endpoint. The compiler should help you out, but if you get stuck, refer back
to the git repo.
Update model to match RxDB requirements
That was such a diversion, that we need to take a moment to remember our goal: to make a graphql endpoint to work with RxDB sync.
Looking at the RxDB docs for
replication, we can see there are
a couple more data model changes needed to make our recipes
and tags
collections need two more fields: updatedAt
and deleted
.
Let’s start by adding them to the recipe
model and leave the more invasive
tags
refactor for a little later. First, add the fields to the type:
type recipe = {
id: id,
title: title,
ingredients: ingredients,
instructions: instructions,
tags: array<tag>,
updatedAt: float,
deleted: bool,
}
This leads to just one compiler error that is easily fixed. We can set default
values on the addRecipe
reducer so we don’t have to change the parameters:
recipes: state.recipes->Map.String.set(
id,
{
id: id,
title: title,
ingredients: ingredients,
instructions: instructions,
tags: [],
updatedAt: Js.Date.now(),
deleted: false,
},
),
Changing the tag model
The current tag model is just a map of tag name to array of string recipe ids.
We need to change this to include the updatedAt
and deleted
flags. To do this
we can introduce a new taggedRecipes
type similar to the one used in the
offline frontend:
type taggedRecipes = {
tag: tag,
recipes: array<id>,
updatedAt: float,
deleted: bool
}
Then the state
type needs to be changed to a Map.String.t<taggedRecipes>
:
type taggedRecipes = {
tag: tag,
recipes: array<id>,
updatedAt: float,
deleted: boolean
}
Now updateTagsArray
needs to be modified to accept an array of these objects.
Because we may be creating a new one if it doesn’t exist, we also need to know
the name of the tag itself, so the parameters are different. And we musn’t
forget that if we are updating something, we need to change the updatedAt
field! In fact, the function name isn’t really suitable anymore. Let’s change
it to createOrUpdateTaggedRecipes
:
let createOrUpdateTaggedRecipes = (
taggedRecipesOption: option<taggedRecipes>,
tag: tag,
recipeId: id,
): option<taggedRecipes> => {
switch taggedRecipesOption {
| None =>
Some({
tag: tag,
recipes: [recipeId],
deleted: false,
updatedAt: Js.Date.now(),
})
| Some(taggedRecipes) =>
Some({
...taggedRecipes,
updatedAt: Js.Date.now(),
recipes: taggedRecipes.recipes->Array.concat([recipeId]),
})
}
}
Obviously the call to updateTagsArray
also needs to be changed to accept the new
tag
parameter and function name:
createOrUpdateTaggedRecipes(taggedRecipesOption, tag, recipe.id)
The /tags/:tag
endpoint needs to be updated to extract the recipes as well. I
just modified it to pass the recipes attached to the taggedRecipes
object
into the pipeline. Change the Some(recipeIds)
to:
| Some(taggedRecipes) => {
let recipes =
taggedRecipes.recipes
->Array.map(id => {
state.recipes
->Map.String.get(id)
->Option.map(recipe => {
let dict = Js.Dict.empty()
dict->Js.Dict.set("id", id->Js.Json.string)
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)
}
}
Conclusion 🐐
This is a little more annoying to test in my REST client because the IDs are no longer deterministic. However, once I remembered to copy the ID into my requests, I discovered that everything was working fine.
I expected to actually start developing the graphql endpoints in this article, but the refactor was bigger than I expected, so I’m splitting that out for the next one.
Notice that I have still not written a line of unit tests, and yet, the major refactors I made did not break the app. It’s never ever ever safe to just rely on the compiler to guarantee that your code is running correctly. But it does allow you to develop with more confidence. With Rescript’s compiler being so incredibly fast, there is no overhead in using it as a early-phase testing mechanism.
I do have an article planned on writing unit tests. I’m definitely going to use the fastest unit test framework I can find. Rescript has taught me that execution speed is more important to development velocity than I used to believe. At one end of the spectrum, slow compiler like Rust or Java slows a programmer down. At the other end, a highly dynamic language like Python or Javascript slows a programmer down as she has to spend more time on automated testing in the early prototyping phases of the project. Rescript feels like it balances everything “just right”, at least for my development style.
The next article will get back to graphql as we hook up some endpoints to fulfill the RxDB replication protocol.