Setting up a Lerna monorepo is pretty straightforward if you follow the official docs. Setting it up for TypeScript is more involved because we have to transpile our code before publishing and this affects navigating the source code in an IDE via Go to definition.
The focus of this article is enabling seamless IDE navigation with no surprises, meaning that Go to definition will work after a fresh clone. A solution with path aliases is presented and two solutions, each with its own advantages and disadvantages, are discussed for building the packages.
Update: If you’re OK with using imports that expose the internal structure of packages e.g.
import foo from '@nighttrax/bar/src/libs/file then you’ll most likely be fine with just the symlinks that lerna or yarn sets up. If however you’re setting up a monorepo of libraries which define entry points through
package.json that don’t point to the source code, then read on.
Let’s start with the folder structure. Assuming we have a monorepo with 2 packages,
bar, it might look something like this:
│ ├── foo
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ └── tsconfig.json
│ └── bar
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ ├── tsconfig.build.json
│ └── tsconfig.json
foo exports a simple constant and package
bar imports it. The direction of the dependency (
foo) is intentional to make sure that we build the packages in the correct order (
bar) and not just alphabetically.
The project root
tsconfig.build.json is your typical TS config and will be used as a base for the other configs. It is named “.build” because it will especially useful when building the packages.
tsconfig.json is the config that will be picked up by an IDE by default and maps absolute package names back to their source code in the monorepo:
paths is the magic sauce here: it tells the TypeScript compiler that whenever a module tries to import another module from the monorepo it should resolve it from the
packages folder. Specifically it should point to that package’s
src folder because that’s the folder that will be compiled when publishing.
paths config should contain one entry per package, or, if they have a common prefix like an organization, a glob that matches them all.
Now for each package we can configure another
tsconfig.json that extends the root one:
This config will be identical for every package and is entirely optional. You can create one if you want to customize settings for each package individually, otherwise the root tsconfig will kick in.
We can see in the GIF above that even after a fresh clone (the clean task makes sure the working tree is not dirty) clicking
Go to definition on an import correctly goes to the source code of that package. With this approach there are no surprises when someone clones the project and tries to navigate it. It also means there won’t be any surprises when someone updates the code since the TS server built into the IDE will just pick up the changes and you won’t have to rebuild the project (to recreate the
dist folders required by other solutions).
Each package will also have a
tsconfig.build.json that will be used for publishing. This config differs from the main one by:
- omitting the
compilerOptions.pathssettings so that the TypeScript compiler will look in
node_modulesinstead of in the monorepo,
- declaring an
outDirthat needs to be relative to each package.
outDir options can’t be hoisted in the root config because they are resolved relatively to the config they’re in.
The `compilerOptions.outDir` config is incorrectly resolved when in a shareable config · Issue…
TypeScript Version: 3.3.0-dev.20181222 Search Terms: outDir, output directory, outDir extends Expected behavior…
Now we need to set up the proper
package.json scripts to compile each package:
The important things here are:
- specifying the dependencies between monorepo packages using
devDependencies(see note below) and
- telling the
tsccompiler to use our
tsconfig.build.jsonconfig via the
If we try to build the
bar package (which depends on the
foo package) in a fresh state (no
dist folders anywhere) then it will fail and complain that it can’t find
foo, even though lerna symlinked it in its
node_modules folder. This is because
package.json defines its entry point via the
main field to point to
dist/index. When the TypeScript compiler sees
import '@nighttrax/foo' in the
bar package it will look for
bar/node_modules/@nighttrax/foo/dist/index.js and, because
foo hasn’t been built (therefore there’s no
dist/ folder in it), will fail.
main field pointed to the original source code then everything would work without any surprises. A package import would be resolved to the symlink that lerna creates in
node_modules and that points back to the source code in the monorepo.
We can solve this problem in 2 ways:
lerna runto build all the packages at once and rely on it building the packages in the correct order by looking at each package’s dependencies.
- Use the project references feature introduced in TS 3.0.
lerna run <script> will run
<script> in each package that has that script defined in its
package.json. It will also run them in topological order, meaning that if package
bar depends on package
<script> will be run before
foo's. This order is created by looking at
devDependencies in each package’s
It is common to use the
prepublishOnly script to run
npm run build before publishing. If you like this approach (and you should, because it makes publishing easier), you need to be aware of some caveats.
Firstly, when publishing a single package (with independent versioning), lerna won’t run the
prepublishOnly script for its dependencies. This means that the package you’re trying to publish might not find the builds of its dependencies which will result in a build error.
lerna run respects the topological order of the packages,
lerna publish doesn’t, or at least it doesn’t compute the same order.
Publishing order should take peerDependencies into account · Issue #1437 · lerna/lerna
Currently the package graph ordering is defined in lerna/core/package-graph/index.js Line 68 in bd79949 …
A workaround to both these caveats is to not rely on the
prepublishOnly script of each package and instead build all the packages before publishing any of them. We can do this simply by calling
lerna run build before
lerna publish which will run each script’s
If you frequently find yourself in the situation of publishing a limited set of packages, or just want to be able to easily debug a build, then the next solution might be more suited for you.
Using project references achieves the same thing as
lerna run — building projects in the correct order — but also allows us to build one package at a time.
Here we modify the previous
tsconfig.build.json to activate project references by setting
composite: true and specifying which other monorepo packages this particular package depends on. The
references config is an array of paths and here we manually specify the path to the
tsconfig.build.json of the depending packages. Each package needs to set
composite: true even if they’re just a leaf node in the reference graph, otherwise
tsc will complain.
rootDir is necessary to create the proper folder structure in
dist/, otherwise TypeScript might infer a root dir higher up the folder hierarchy and output unnecessarily nested folders.
Now if we change the
compile script in
package.json to run
tsc -b, we can build a single package and TypeScript will build any project dependencies for us. We also need to update the
clean script to remove the build cache for
tsc -b, otherwise we can run into a situation where if the
dist/ folder of a package is removed, but the package itself doesn’t contain any changes from last time,
tsc -b will not rebuild it.
lerna run will work like before, so the main advantage of this solution is that it allows us to debug package builds without worrying about the other packages.
Unfortunately we cannot hoist the
references settings up to the root config so this means we end up declaring the same dependencies between packages both in
package.json and in
tsconfig.build.json for every package.
So there we have it. While both solutions have some minor downsides, I think we can still have our cake and eat it — albeit maybe a smaller cake than we’d like. You can check out the full example at the following repo:
Example TypeScript monorepo with Lerna. Contribute to NiGhTTraX/lerna-ts development by creating an account on GitHub.
I’m curious to know if there are better solutions out there, particularly ones that reduce duplication when it comes to declaring dependencies. The microsoft/TypeScript repo has an open issue about automatically inferring project references from lerna configs, but there doesn’t seem to be any progress on it.
Infer project references from common monorepo patterns / tools · Issue #25376 ·…
Genesis: see section "Repetitive configuration" in #3469 (comment) Search Terms monorepo infer project references…
If you have any questions or suggestions please leave them in the comments section or open a GitHub issue in the repo!