Check out the Git repo: https://github.com/alexnitta/woodshed
About a year and a half ago, I transitioned from a job at a mature company to a job at a new startup. For the most part, I’m the only frontend developer at my company, and I essentially started building our web application from scratch. This was exciting and daunting at the same time. Exciting in terms of the freedom to choose technologies that I was interested in working with, and daunting in terms of the possibility that I could make choices I would later regret – or worse, that other developers or users would blame me for.
This post will go into the reasons why I chose particular technologies when starting from scratch, and how I feel about those choices now that I’ve been working with them for a while. I’m hoping to write several posts in this vein.
I was coming from a frontend team of around five in-house engineers, and the main bottleneck in our workflow was how the Git repos were structured. We had over thirty frontend repos, and most tickets required making changes in several of them. This turned merging in a pull request into an exercise that was time-consuming at best, and at worst, prone to merge conflicts and regressions. These problems only got worse when we brought on a team of contractors to increase our velocity. We spent many hours on-boarding them to our esoteric workflows, and more time still on merging in their changes, which typically lived in long-running feature branches that drifted apart from our development branches across our many repos. One of the our oldest tickets, which was always getting pushed aside in favor of fixing more urgent problems, was migrating to a monorepo. We had started doing this with Lerna, and then re-started the effort using plain npm and Git commands, but neither of these projects had succeeded by the time I left.
Not surprisingly, this pain was fresh in my mind when I started from scratch at my new job. Things would be different, I vowed, and I committed myself to building a monorepo from the start with Lerna. There were other options, but Lerna had the most promising community of active users and maintainers, and I was already somewhat familiar with it, so it was an easy choice amongst the available tools.
I also chose to use TypeScript from the start, without having prior experience with it. This was a choice driven by a desire to write fewer bugs, as well as personal interest in learning a new technology. I had worked with Flow in an earlier job, and TypeScript felt familiar in some ways, but was clearly gaining more traction in the frontend community. More and more new projects were being written in TypeScript, which meant I could take advantage of their typings in my own code. Also, it just seemed to make career sense; I was seeing it mentioned in more job listings over the years.
I’ve become so used to writing code in React that it’s second nature to reach for it as the main frontend library, and build off of that choice. Plus, I already had decided to use two new tools (Lerna and TypeScript), and I needed to make sure I could still ship code at a reasonable pace. Besides, the company had hired me with the expectation that I would leverage my prior React experience – so it made a lot of practical sense.
Lerna, TypeScript and React: benefits
There is a lot to gain from using Lerna, TypeScript and React together. The most obvious benefit is code portability. This is why we split our applications into multiple packages in the first place, and it can be a great advantage when working within a startup which may need to pivot quickly. At my new company, we started out building an Electron app, then later switched to building a web app. Since I had already set aside much of the React components and hooks into packages, I could simply start another package for the web app and import the existing code there.
Using a monorepo makes it easy to enforce code conventions with shared config files for tools like ESLint and Prettier. These tools take time to configure; the good news is that in a monorepo you can maximize the benefits of that time you spent by applying your configs across all of your packages. And Lerna’s CLI gives you an easy way to run package.json scripts in parallel, so you can lint and test your code before submitting a PR and during continuous integration.
Lerna, TypeScript and React: drawbacks
The unpleasant reality is that these tools are not necessarily built to work smoothly together. That’s why there are several new frameworks that promise to simplify frontend development workflows, such as RedwoodJS and Blitz. However, these frameworks make some choices that may not be appropriate for your project. If that’s the case, keep reading – there are ways to make it all work; it just took some good old learning by experience to uncover the gotchas.
tsc won’t save you, but Webpack and Babel can help
The main thing to remember when building a monorepo with TypeScript is that the TypeScript CLI (or
tsc) is mostly written for people developing a library as a single npm package. The assumption is that you will want to write your library in TypeScript, then use
tsc to compile your code, you would need to run it in
--watch mode in every package, so that it can listen for changes in the TypeScript files and transpile them to
.d.ts files for other packages to import. Even if you use Lerna to run these commands for you in a single package.json script, each of those running
tsc commands will eat up some resources and take time to do its part of the work.
A better approach is to use the top-level application to transpile the entire codebase. By top-level, I mean the package that exists at the root of the dependency tree. In a typical web app built with Create React App, this means that the Webpack config needs to be modified so that Babel knows how to handle TypeScript appropriately across all the monorepo packages. This is where things get tricky. There are several tools out there for modifying your Webpack / Babel config without ejecting from Create React App. I found a combination that works, but not without a lot of tinkering.
Beware the silent
It’s commonly accepted as best practice to avoid using
any types in a TypeScript project. Let’s imagine you are depending on
eslint to enforce this practice. So far, so good. Eventually, you might discover that you can declare global type definitions and use them across your monorepo by adding them to the
compilerOptions.typeRoots section of your
tsconfig.json files. Let’s also imagine you write a global type definition that uses type defined in a library, such as React. If you are more experienced with TypeScript, you might know how to write an inline
import statement to bring that type into your global definition. But, if you are relatively new to TypeScript, as I was, you might just refer to that type without importing it. In this case – for some crazy reason – TypeScript will just happily accept your declaration that uses a type that it has not actually imported, and silently treat it as an
any type, despite your rules that don’t allow
anys. And that can create some nasty hidden bugs, as I discovered. Lesson learned: understand how the TypeScript
import keyword works, and use it in your global type definitions.
See an example boilerplate
If you search for examples of Lerna / TypeScript / Create React App projects, you will find lots of unmaintained boilerplates. I have probably tried my hand at leveraging most of them. In the end, I decided to share my own version, which replicates the methods that are working for me in production. I hope you find it useful: https://github.com/alexnitta/woodshed
Where this choice really shines, though, is when working on a team. I mentioned that I’m usually the only frontend developer on my team, but from time to time, other people do contribute. When they do, they only have one repo to
git clone and they only have to submit one PR when it’s time to merge in their changes. When that happens, I take a moment to breath deeply and appreciate how much time I’m not spending on the process.