ReScript compilation speeds are really fast out of the box. But, sometimes editing certain files can take longer to compile than you'd expect it to.
In this article we'll explore a strategy for speeding up compilation by leveraging interface files. We'll also dive into why using interface files can speed up compilation. And, by doing that we'll dig a bit into how the ReScript compiler works internally.
Let's start from the beginning though, with a few concepts that are necessary to understand to be able to follow the rest of the article. You can skip the coming sections if you're already familiar with modules, interfaces and implementation files in ReScript.
TLDR; If you have files that, when edited, cause longer compile times than you'd expect, see if adding interface files for those files help. If you're changing the implementation often, but not the interface, you might see massive speed increases from adding interface files.
Modules and files
Read the official docs on modules: Modules in ReScript
Quoting the official docs, "modules are like mini files!". A module is a collection of types, values, functions, and so on.
For this article's sake, pay special attention to the fact that every file is automatically a module. So, if you have MyFile.res
, that file's contents will be available for you to use under the MyFile
module globally.
We'll use the words module and file interchangeably, so please keep in mind that every file is a module.
Implementation and interface files
Read the official docs: Interface and implementation files in ReScript
ReScript lets you separate interface and implementation in various ways.
Interface here means a description of what the outside world has access to in your module. This is typically called the signature of the code. It's essentially type information.
Implementation means the code you write to fulfill that description.
A simple example: in your interface, you tell the world there's going to be a value called usernames
available that's an array of strings. And in the implementation, you produce that actual array of strings called usernames
.
// AllUsers.resi
let usernames: array<string>
// AllUsers.res
let usernames = allUsersInSystem->Belt.Array.map(user => user.name)
Now, the outside world doesn't need to care about how the usernames
array is produced. That's the implementation. It just needs to know that that array will be there.
In ReScript, implementation files are suffixed with .res
, and interface files are suffixed with .resi
. So, if you have AllUsers.res
, you add an interface by adding a AllUsers.resi
file.
So, how can interface files speed up compilation?
Why does all of this matter to compilation speed? Let's dive into how the ReScript compiler works a bit.
When you make a change to your code and then recompile, the ReScript compiler needs to type check all affected parts of your program to make sure everything still adds up. The ReScript compiler is quite intelligent and mostly knows how to check the minimum set of files needed as you change things. This is a huge part of what makes the compiler so fast.
Type checking is, very simplified, about ensuring that all the types match each other in the project. Is the parts of your program expecting (and receiving) the right things?
The compiler looks at each file (module), and then does one of two things depending on whether that file has an interface file or not.
- If it has an interface file, the compiler checks the implementation file to make sure that the implementation matches the interface. Are you delivering what you promised in the interface? If all checks out, it moves on. If not, it errors.
- If it doesn't have an interface file, the compiler needs to derive an interface by itself for the file by looking at the implementation file directly. So it looks at the code you've written, infers the signature of that code ("
let myArray = ["some string"]
is an array of strings"), and then uses that inferred signature as an interface for that file as it moves on.
And this is because the type checker works on the type information from the interface, not on the actual code implementing that interface.
Compilation slows down because the compiler needs to derive the interface again and again
This is what it all comes down to. If there's no interface file, the compiler needs to derive that interface itself by looking at the implementation.
And it needs to do that every time the file changes.
And after it has done that, it needs to recheck all of the modules depending on the file you changed to make sure things still type check.
But, if there is an interface file, all the compiler needs to check is that your implementation still matches the interface. It doesn't need to check anything else. It can safely assume that any other file that's depending on this file and has compiled successfully with the current interface is still valid. Because the interface hasn't changed, just the implementation for that interface.
A good strategy for interface files
How much you make use of interface files or not is largely a philosophical question that I'm going to refrain from discussing here. Some people never use them. Some people use them a lot. I personally use them some, but not a lot.
One strategy I will leave you with though is to be mindful of modules you have that are used by many other modules. Those might be a good idea to use interface files for, not the least because of the speed benefits discussed in this article.
So, be mindful of when editing certain files cause longer compilation times than you'd expect. See if adding an interface file might help.
Editor tooling helps make life with interface files easier
The ReScript VSCode extension has two goodies that help make life with interface files easier that I want to mention.
Automatically generating an interface file from an implementation file
If you have a .res
file that you want to create an interface file for, you can use the command > ReScript: Create an interface file for this implementation file
(Cmd/Ctrl + P
brings up the command prompt). You'll get a fully filled out .resi
file right next to your .res
file that you can edit to your liking.
Quite convenient.
Switching between interface and implementation files
The extension makes it easy to jump between the .res
and .resi
file for a module via the command > ReScript: Switch implementation/interface
. Running that command will jump to the .resi
file if you're in a .res
file (and the .resi
file exists of course), and running it from a .resi
file will jump to the corresponding .res
file.
Wrapping up
There's a ton more to say when it comes to what you can use interface files for, both in terms of features, but also in terms of techniques for a good developer workflow. This article focuses on the performance aspects of interface files, but I'm likely going to cover the other points too at some point.
Until then, thank you for reading!
Top comments (3)
If we cannot get rid of interfaces in ReScript without compromising on compilation speed or having uglier syntax for information hiding (
%%private()
), the next best thing would be to automate updating interface files when the implementation gets saved. It would be a different mode that you can activate, maybe even per file.Yeah I think going for improving the existing workflow is the more pragmatic bet. Getting rid of interface files is probably not a small task, and besides I'm pretty sure there's no consensus on that being the reasonable path forward. I know I wouldn't want to get rid of them, for one. I quite like them.
But, there are many things we can improve today:
Great article! But I feel like you only touched on part of why it’s faster; when editing an implementation and you don't change the interface the compiler can guarantee no other files need to be compiled, only the edited module. If an interface file is updated every module that depends on it needs to be recompiled in case there are breaking changes.
With derived interfaces there is no interface file to protect against this requirement. Every time the file is saved the compiler treats it like the interface changed and triggers the full dependent recompile. If the dependent files also lack interfaces this can very quickly spiral into a huge compile process.