How I structure ReScript projects for great success

Ever wondered what are some patterns for writing ReScript in large projects? Here's a handful of them from working in a startup with >300,000 lines of ReScript.

How I structure ReScript projects for great success

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 everywhere
  • keep your external bindings in their own packages
  • every 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 or Js or Relude

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 ts 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.