How to set up a TypeScript monorepo with Lerna

Andrei Picus
May 28 · 7 min read

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, foo and bar, it might look something like this:

.
├── lerna.json
├── 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.

packages/foo/src/index.ts
packages/bar/src/index.ts

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.

Your typical tsconfig

Path aliases

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:

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.

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 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 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 tsconfig.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.

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

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

Andrei Picus

Written by

React, TDD, TypeScript

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade