How to set up a TypeScript monorepo and make Go to definition work
The focus of this article is enabling code navigation in a TypeScript monorepo work seamlessly without any surprises, meaning that Go to definition in an IDE will work after a fresh clone, without having to build the projects beforehand. A solution with path aliases is presented and two solutions, each with its own advantages and disadvantages, are discussed for building the packages.
You can find the full examples and more in the following repo:
If you would like to add create-react-app, NextJS, ts-node or other tools into the mix then check out my followup article Making TypeScript monorepos play nice with other tools.
First thing’s first, let’s setup the monorepo. You can use Lerna or yarn workspaces to create the monorepo structure and symlink the packages according to the dependencies between them. The second part of this article will use Lerna to manage building the packages in the correct order, but it doesn’t require you to use Lerna to manage the monorepo, so you can choose whichever tool you prefer for setup.
Take the following example, which shows a simple monorepo with 2 packages, foo
and bar:
.
├── package.json
├── packages
│ ├── foo
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ └── tsconfig.json
│ └── bar
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ ├── tsconfig.build.json
│ └── tsconfig.json
├── tsconfig.build.json
└── tsconfig.json
Package foo
exports a simple constant and package bar
imports it. The direction of the dependency (bar
-> foo
) is intentional to make sure that we build the packages in the correct order (foo
, then bar
) and not just alphabetically.
Notice the pkg.main
config in the package’s package.json
that’s pointing to the build folder and not the source folder. When another package in the monorepo tries to import this one, or when you try to navigate in an IDE from an import of this package, the build folder must be there and be up-to-date in order for everything to work correctly. This has many disadvantages including having to build the monorepo after cloning and having to rebuild it on every code change. Moreover, the IDE will navigate to the JS output, instead of the TS source. Let’s see how we can instruct both the IDE and the TS compiler to navigate to the monorepo source instead.
The project root tsconfig.build.json
is your typical TS config and will be used as a base for the other configs. The “normal” tsconfig.json
will contain the settings required to enable seamless navigation in the IDE. The configs need to be separate because the settings required for code navigation need to be turned off when building.
Path aliases
tsconfig.json
is the config that will be picked up by an IDE by default and its role is to map 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.
The 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.paths
settings so that the TypeScript compiler will look innode_modules
instead of in the monorepo, - declaring an
outDir
that needs to be relative to each package.
Unfortunately, the include
and outDir
options can’t be hoisted in the root config because they are resolved relatively to the config they’re in.
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
dependencies
ordevDependencies
(see note below) and - telling the
tsc
compiler to use ourtsconfig.build.json
config via the-p
or--project
flag.
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 foo
's 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.
If the 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:
- Use
lerna run
to 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
lerna run <script>
will run <script>
in each package that has that script defined in itspackage.json
. It will also run them in topological order, meaning that if package bar
depends on package foo
then bar
's <script>
will be run before foo
's. This order is created by looking at dependencies
and devDependencies
in each package’s package.json
.
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.
Also, while lerna run
respects the topological order of the packages, lerna publish
doesn’t, or at least it doesn’t compute the same order.
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 build
script.
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.
Project references
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:
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.
If you have any questions or suggestions please leave them in the comments section or open a GitHub issue in the repo!
Thanks for reading and if you enjoyed this article then maybe you’ll enjoy reading my other articles or checking out some of my other open source projects: