Using TypeScript project references in NX

NX.dev is a great mono repo tool and it comes with a number of project types which reduces how much config you need to maintain.

One issue we have on our bigger mono repos is the TypeScript language service is quite slow, also we pay the cost of type checking in linting, building, local dev, testing etc.

To fix this we are moving to a setup where:

  • We use TypeScript project references, and run tsc -b --watch in a vscode task to give us quick incremental type checking
  • Use esbuild-loader, esbuild and vite to ensure our builds and local dev experience is super quick.
  • Use babel-jest for testing, jest still needs babel so esbuild-jest didn’t actually provide a performance boost over just babel-jest

This effectively means we will no longer be using ts-loader, ts-jest and other projects which internally use the TypeScript compiler. Before I continue I just want to say thanks to folks like https://twitter.com/johnny_reilly and others who have built and maintained these tools which have really contributed to the success of TypeScript in the React / greater JS communities.

Compatible plugins

Most NX plugins do not take advantage of this setup, so we built our own. The goals are to remove a heap of the hidden and complex configuration in NX but get the benefits.

Check out the plugins at https://github.com/sevenwestmedia-labs/nx-plugins, but they are not required to do this. They just make it easier.

Above is the main project, which contains a migration generator to do a bulk migration of your project. See below for more info on this.

It also contains a library generator and a package executor to pack an NPM library (using tsup).

Generator for NodeJS applications with build/serve targets using ESBuild

Quickly add a infrastructure project for another project in your repo using Pulumi.

Finally, make it easy to use Vite in an NX repo.

TypeScript in mono repos Background

There are two ways you can configure your TypeScript mono repos

  1. Path mappings
  2. TypeScript project references

Path mappings is the path of least resistance, it basically means that when you import another library in your project the bundler/tool can follow that reference and import the source as if it was one giant project.

The downsides of this is in larger projects with multiple applications sharing large libraries is the shared libraries are type checked multiple times.

The second option is TypeScript project references, this allows TypeScript to understand the projects, build a dependency graph, then build the libraries before the applications.

The application (or libraries which reference other libraries) will then look at the built artifacts (the .js + .d.ts files) rather than the source (ie, /dist, not /src). Essentially it allows TypeScript to treat our projects just like it treats NPM modules. .d.ts files are much quicker for TypeScript to parse and understand than the source code.

This means when you update code in src/, tsneeds to run to update dist/ before the projects referencing that library will see the changes. This can create developer experience problems (due to referencing out of date types), but gains us a massive performance boost.

Unfortunately there are many tools in place in a mono repo, you generally will have a bundler (webpack, esbuild, vite, babel etc), jest (babel + a transformer like esbuild, ts-jest, etc), node (or ts-node or esbuild/register for running your node code locally).

When using TypeScript project references and multiple tools which run the TypeScript compiler, each tool either has to assume something is building the dependencies, or they have to build them themselves. If they built them themselves they often do not support features like incremental compilation etc.

It is far cleaner to just have TypeScript do the type checking and build libraries for TypeScript project references, then all other tools simply bundle source only in the mono repo really fast.

My approach

At a high level my goals are:

Use tsc in watch mode to ensure VSCode is always looking at the latest library type information.

Configure the bundler/linter/jest/node etc to *ignore* typescript project references and just treat it as one big project of source code.

None of these tools should be configured to use the TypeScript compiler internally, meaning no ts-node, ts-jest, ts-loader to avoid paying the cost of the type checker multiple times.

Automatic migration

We have created an NX generator which automatically performs the majority of the conversion steps below. The goal is that it can do the bulk of the migrations, then we will also have NX generators to create new NX projects which have compatible config.

The repo is available at https://github.com/sevenwestmedia-labs/nx-generators

To run the generator, run the following

npm add -D @wanews/nx-typescript-project-referencesnx generate @wanews/nx-typescript-project-references:migrate

Then review all the changes, this is still a work in progress so it may miss things or remove some custom config you need.

What it does

The automatic migration will continue to grow and ideally be idempotent so it can be run again after you upgrade NX.

  • Creates a root tsconfig.json file which references all projects
  • Deletes tsconfig.app.json, tsconfig.spec.json and tsconfig.lib.json depending on which are available to leave you with one tsconfig per project
  • Switches to babel-jest from ts-jest
  • Removes type checking from eslint
  • Configures project references based on NX dep graph

There may still be additional work to finish the conversion depending on what NX plugins you use.

For more info on how TypeScript project references work, https://jakeginnivan.medium.com/breaking-down-typescript-project-references-260f77b95913

Wrapping up

NX’s out of the box plugins for React, Node etc are pretty tied into the webpack ecosystem and don’t work well with TS Project references. By moving away from the built in plugins and just using NX’s core you can use more modern JS build tooling, have tsc running type checking across your repo easily and make it easier to see what is going on.

Tech lead for @sevenwestmedia WA | Speaker | Mentor | OSS enthusiast | Full Stack TypeScript, JS, React | Creator of GitVersion | 🥃 ☕️ 🐶 😸