Structuring large ReScript projects
Here's how we structured our >300k LOC ReScript project at Walnut
One of the great things about writing ReScript is that even if you code as fast as you can and with zero afterthought or planning, you'll end up with a solid codebase that you can refactor very safely. (Seriously, the type system is amazing)
But with just a little bit of planning, you can go from safe-but-messy refactors to a wonderful experience.
This is advice that comes from working primarily with Walnut's codebase of >300,000 LOC, where we routinely did very large refactors to a complex application.
The refactors included swapping out our entire I/O layer from Relude to Promises in a single go, standardizing on ->
for piping, introducing and enforcing design patterns, and more.
The tl;dr is:
make a
stdlib
package and open it by default everywherekeep your
external
bindings in their own packagesevery package meant as a library includes a top-level interface
nest folders within a package to show the module hierarchy
Making a stdlib
package
This one is definitely gonna ruffle some feathers, but I strongly believe this was one of the best moves to help onboard new people and standardize how we wrote ReScript.
The recipe is:
make a new package in your repo and call it
stdlib
include this package in every other package in your repo
in your bsconfig.json make sure this package is opened by default
use this repo to standardize what your primitives will be
What this buys you is:
you have a uniform standard library to write all your code on
you can extend this library as your needs change
you can enforce how to do certain things from there
The trade-off:
You gotta make sure new packages have the same
bsconfig.json
You gotta tell people to not open
Belt
orJs
orRelude
Here's how it works.
First, we'll make a top-level module in our new package, Stdlib.res
. This module is the standard library so it must include all the things that you expect it to have. Here's a sample:
// {CompanyName}'s Standard Library
module Array = Array
// ...
module Option = Option
module Promise = Promise
module Result = Result
module String = String
// ...
Then, you'll make new modules for each one of these things. Like this:
stdlib
└── src
├── Array.res
├── Array.resi
├── ...
├── Option.res
├── Option.resi
├── Promise.res
├── Promise.resi
├── Result.res
├── Result.resi
├── String.res
└── String.resi
And inside each one of those modules, you'll begin by including whatever base you want to use.
We love Belt
, but preferred Js
for some things, so some of our modules began with: include Belt.Option
and some others began with include Js.Array2
.
This also meant that if we needed to borrow a function from Belt.Array, or Js.Array(1), or even Relude.Array, we could easily include it in our own Stdlib.Array
by either:
copying the function code in there
aliasing the function (
let cmp = Belt.Array.cmpU
)
This also means that we can enforce patterns, like making sure all functions are t
-first, or wrapping/hiding functions that throw exceptions.
For example, by not including getExn
in your Array.resi
, then because this library shadows the Array
module, that function won't be available.
It is also great for documenting new functions, like generic Promise
combinators for working with result
values, and for other things you consider part of a standard library.
That point is worth mentioning too. Nothing beats going Uuid.v4()
knowing that everyone is using the Standard Library UUID.
Keep your Externals in Separate Packages
This has a high cost compared to just writing external
right when you need it, in the same file that needs it, but it pays off over time.
Here's the recipe:
every new 3rdparty library you write bindings for has a matching package
keep it simple, write bindings only for what you really need
use abstract types to keep things encapsulated
wrap externals that throw exceptions into
result
functions
What it buys you:
everyone gets to use a library in the same way
adding to the bindings helps everyone else that's using them
replacing/extending the underlying 3rdparty library is much easier
The trade-off:
Higher cost to set up than just writing
external
where you need it
Here's how it works.
First you'll create a new package for your 3rdparty library, like packages/3rdparty/uuid/bsconfig.json
. In there, you can keep things simple and just have a single module Uuid.res/resi
.
//file: Uuid.res
type t
external v4 : unit => t = "new_v4"
///file: Uuid.resi
// a UUID
type t
// Create a new UUID v4
let v4 : unit => t
If you wrote this binding every time you needed a UUID, then all your t
s will be different, and any new functions you create to deal with them ad-hoc will not work together at all.
You could make v4
just return string
, but then that's a different thing than a Uuid.t! If you needed a UUID and instead used string
as an input type somewhere, then don't get mad if someone types in the entire works of Julio Borges in Korean and the thing crashes later. After all, that's a valid string
.
So this pattern helps you quickly unify what it means to be a Uuid
, and what are the things you can do with them.
More complex bindings to libraries may require more work than this, but you can absolutely start here.
Package Interfaces
If part of your application is built on pure-logic libraries, then including a top-level interface will ensure that everyone consuming that library within your ReScript project gets a uniform experience.
This is the recipe:
If some data/logic should exist only once (like design system colors, or some critical business logic), then make it a package
Slap a
.resi
on it and only expose what you really need
This is what it buys you:
Refactoring that critical package becomes much easier since you only have a tiny public API
Documenting that critical package becomes possible
You're less likely to duplicate some of this logic/data in other places
Makes it easier to test in isolation, since it is pure ReScript code with little or no dependencies
This is the trade-offs:
Identifying what is critical can be hard when you're starting out a project, so this works best when you have experienced people
Can be a slow process to apply to an existing codebase
This is how it works.
Say you're finally standardizing your color palette because its a mess right now. You'll create a new package, call it design_system
, and in one module start moving your components and design tokens there: Button component, Primary color token, Navigation component, etc.
If you create an interface and hide your design tokens, you may find that a lot of code breaks! And this would be a great indicator that some code relies on the DesignSystem.Tokens.primary_color
. It'll help you figure out if this component should be moved to the design_system
package, or not.
On existing codebases, you'll see slow progress, and you'll likely end up with code in this package that probably could be moved out. That's okay, this stuff takes patience.
On newer codebases, you can benefit from this starting on day 1, and never look back.
Folder Hierarchies
Lastly, a common problem in large ReScript apps is how to put files in folders. There are some big caveats:
ReScript has a flat module namespace, which means that your folder structure within a given package doesn't actually matter
All ReScript modules are entirely public to each other by default
There are 2 patterns I can recommend here: keeping it flat and following the module prefix.
Keeping it Flat
When you're keeping things flat, you focus on the top-level module, and you make sure there's an interface that shows you how the submodules should be used (if at all).
This works best for smaller packages or packages where the domain doesn't have a lot of nesting. For example, a Component library:
components
└── src
├── button.res
├── components.res
├── components.resi
├── header.res
├── icons
│ ├── github.res
│ ├── logo.res
│ ├── no.res
│ ├── ok.res
│ ├── print.res
│ └── twitter.res
├── icons.res
├── input.res
├── navbar.res
├── section.res
└── tokens.res
Then you do the work of organizing the API in the components.res/resi
files:
module Button = Button
module Header = Header
module Icons = {
include Icons;
module Github = Github;
module Logo = Logo;
module Ok = Ok;
// more icones here
}
// more modules here
The main advantage here is that the organization of the package depends on a single spot.
The main disadvantage is that you can't reuse module names!
So if your navbar
or button
component grows larger and needs to be split into a Pure Component, a State module, and a Container, then you can't do this:
components
└── src
├── button.res
├── button
│ ├── state.res
│ ├── pure.res
│ └── container.res
├── navbar.res
├── navbar
│ ├── state.res
│ │ ├── action.res
│ │ └── reducer.res
│ ├── pure.res
│ └── container.res
├── ...
└── tokens.res
Instead, you can use the next pattern.
Follow Module Prefix
If your module hierarchy is getting deep, then you're likely running into the problem of multiple modules having the same name, but a different prefix.
For example, in the last section, we saw that a Button module would be split into 3 sub-modules: Pure, State, and Container.
Unfortunately, there can only be a single Pure module. That name must be unique!
So what we do instead is name our files following the entire module prefix, like this:
components
└── src
├── button.res
├── button
│ ├── button__state.res
│ ├── button__pure.res
│ └── button__container.res
├── navbar.res
├── navbar
│ ├── state.res
│ │ ├── navbar__state__action.res
│ │ └── navbar__state__reducer.res
│ ├── navbar__pure.res
│ └── navbar__container.res
├── ...
└── tokens.res
Then you'll want to make sure your Button module exposes the submodules correctly:
// file: Button.res
module State = Button__state
module Pure = Button__pure
// NOTE: make the Container version the default,
// so people can use `<Button .../>` directly
include Container
If you don't need to expose the submodules, then you need to use the top-level package interface to limit what is visible. Like this:
// file: components.resi
module Button : {
@react.component
let make : {..} => React.element
}
If you don't do this, then both Button
and Button__State
will be accessible to the rest of the application.
If you do this, then you have to keep your .resi
up-to-date as you add more components.
There are also patterns for building up interface files from smaller parts, so every component can define its own interface, but we'll deal with those in another post.
Conclusion
That's it. These are some recipes that will help you write LARGE ReScript codebases while keeping your teams aligned and happy on how to do it.
Hopefully, you can go back to your codebase and see ways in which you can improve it not just for yourself, but for everyone else working with you.
And while we're here, if you've got some patterns that you've found on your own or that you're using that are helping you, I'd love to hear about 'em! Tweet at me @leostera
Thanks to @diogomafra_ for reviewing an early draft of this post.