How to set up a TypeScript monorepo and make Go to definition work

Image for post
Image for post

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.json
│ └── bar
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ ├──
│ └── tsconfig.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 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.

Your typical tsconfig

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:

Using paths to alias our package names back to 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:

Each package will have one of these

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.

Image for post
Image for post
Go to definition works after a fresh clone (and npm install/bootstrap)

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 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 in node_modules instead of in the monorepo,
  • declaring an outDir that needs to be relative to each package.
Each package has a separate config for building

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:

Your typical package.json

The important things here are:

  • specifying the dependencies between monorepo packages using dependencies or devDependencies (see note below) and
  • telling the tsc compiler to use our 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.

Image for post
Image for post
foo is not built so bar can’t find it

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:

  1. 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.
  2. 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.

Using project references to automatically build the package graph

Here we modify the previous 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 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.

package.json with project references

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 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:

Written by

React, TDD, TypeScript

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store