Lindsay Edwards

Monorepo tooling will fight you

On this page

The code in a monorepo was fine. The tooling around it was the problem, three times in one week. None of the failures were about logic. They were about how the compiler, the package manager, and the bundler each see a multi-package layout.

I want to write these down because they all cost me time, and they are all the kind of thing you only learn by hitting it. None of it was glamorous, but a build that will not compile ships nothing, so a day lost to tooling is a day the product does not move.

rootDir fights workspace imports#

The first one was a compiler error that made no sense: a file was “not under rootDir”. The file was right there, in a sibling package, imported the normal way.

Each package had its own rootDir set in its compiler config. That felt tidy. The problem is that rootDir tells the compiler every input file must live under that one directory. The moment a package imports a sibling through the workspace, that sibling is not under the importing package’s rootDir, and the compiler refuses.

{
"compilerOptions": {
"rootDir": "./src" // now nothing outside ./src is allowed as input
}
}

The fix was to remove rootDir entirely and let project references do their job. Project references already know how packages relate and where each one’s output goes. Once rootDir stopped narrowing the input set, sibling imports resolved cleanly. The setting I added for tidiness was the whole cause.

Pin the major of anything on a hot path#

The second one was worse because it broke everything at once. A validation library was used across most of the packages. A transitive dependency pulled in a new major version of it, the major had breaking changes, and suddenly every consumer failed to compile.

One version bump, dozens of broken files, and none of my code had changed.

When a library sits on a hot path across many packages, a single major bump is not one break. It is one break multiplied by every package that touches it.

The lesson was to pin the major version of anything that is shared across packages on a hot path. Let patches and minors float if you like, but do not let a major slip in by accident when it can take out every consumer simultaneously.

{
"dependencies": {
"some-validation-lib": "3.x" // pin the major, breaking changes stay opt-in
}
}

Now moving to the next major is a decision I make on purpose, in one place, not an ambush delivered by a transitive dependency.

Reach for the externals escape hatch#

The third one came from the bundler. The build failed while tracing an adapter my app never calls. An auth library eagerly imports an optional adapter for a backend I do not use, the bundler followed that import while building the dependency graph, and it choked on code that would never run.

My instinct was to go untangle my own imports. That was the wrong instinct, because the import was not mine. It was inside the library, and I was never going to invoke the code anyway.

The right fix was the bundler’s external-packages escape hatch. You tell the bundler to leave that package alone and not trace into it. It stops walking code you never call, and the build passes.

// tell the bundler not to trace into it
export default {
serverExternalPackages: ["the-auth-library"],
};

The dead adapter is still never invoked, so nothing is lost. I just stopped asking the bundler to prove it works.

Three tools, three opinions#

In a multi-package TypeScript setup, the tooling has opinions that a single package never exposes you to. A per-package rootDir fights workspace imports, so remove it and let project references resolve. Pin the major version of any shared library on a hot path, because one bump breaks every consumer together. And when a bundler chokes on code you never call, reach for the externals escape hatch before you start refactoring imports that were never yours to begin with.

Keep reading