Breaking down TypeScript project references

TypeScript project references are a reasonably new feature in TypeScript which allows you to break your project up into a number of smaller TypeScript projects. This improves compilation and VSCode editor speed/reduces memory usage etc.

You then can run tsc -b in your repo and it will build each project in the right order.

Once built, projects reference 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 is what makes it faster than using Path Mapping. But it can be hard to get everything working nicely together.

Interoperability with other tools

The main struggle I have had in the past with project references is getting them working nicely with Jest, Webpack, ESbuild etc.

The setup I have landed on is that I want my Type Checking to use project references but my test and builds simply follow path mappings and treat the whole project as a single project.

This allows me to run the TypeScript compiler in watch mode with incremental compilation, giving me fast feedback on type errors, then tools like ESBuild can parse TypeScript code without type checking and compile my apps *really* fast. Same with Jest, it should just use babel-jest so the TypeScript compiler is not in the mix. Same with webpack either use esbuild-loader or babel-loader.

Why not ts-jest/ts-loader

At a base level there is a mismatch between the goals/architecture of the type checker and the transpiler responsibilities of TypeScript. Many tools in the JS ecosystem can see a single file changed, then run that file through it’s pipeline then do hot module replacement just for that chunk. Newer tools like Vite which take advantage of ES Modules in development don’t even need the bundle step so they compile files on demand.

The nature of a type checker in comparison cannot just look at a single file and it’s dependencies. It also needs to know which files reference the updated file and re-check those too.

For example, if I change a module with a function, introducing a new parameter to a function then save, transpilers can just emit that new file, TypeScript needs to check all files which call that function and emit a type error because you are not specifying enough arguments.

The ts-* plugins for jest, webpack etc all have to fight with this mismatch, so I figure it’s best to just use tsc for what it does well and lean into the new, fast tooling ecosystem.

The moving parts

This breaks down the different config files and settings involved in making TypeScript project references work well.

1. Create a solution tsconfig.json

This allows VSCode to discover all our projects and also allows us to run tsc -b from the root of our project to build/typecheck everything

The disableSourceOfProjectReferenceRedirectis important so the TypeScript language service does not ignore the project references and we lose the performance gains. This means we must run the typescript compiler in watch mode in the background to ensure our library output is always up to date.

The references now need to point at the various project folders. Each folder needs to contain a tsconfig.json.

2. Create tsconfig.base.json

This file contains our base TypeScript options which all projects and other tools will use. We want our setup to work with Webpack, jest, ts-node etc

This file will also contain Path Mappings between our projects for Jest / Babel / ESBuild etc to be able to resolve our project references too.

2. Introduce tsconfig.settings.json

This file is for project references projects, we will leave tsconfig.base.json in place for ts-node and other similar tools.

3. Add project tsconfig’s

We want to have a single tsconfig.json per project. It should be rather simple, just extending from our settings config, specifying the root/out dirs and including the ambient types we want in that project.

4. Setup linting to not type check

ESLint can run with type information, unfortunately this is much slower. This step is optional depending on if your linting requires this information.

As an example, no-floating-promises requires type information so if you perform this step this rule will now fail.

To remove type info from linting, just ensure the parserOptionsconfig is no in your .eslintrc files.

I will likely look into enabling a full lint with type info via the cli only to give the best of both worlds in the future.

5. Configure babel-jest

TS-Jest is a great project for smaller typescript projects, but as the project size grows we need a different strategy.

With Jest we want to ignore project references and instead use the TypeScript project path mappings.

We still want to use the path mapper in ts-jest, it covers a few scenarios which the other projects don’t. It would be good if it was a separate package.

6. Create/update package.json for libraries

The package.json is important for TypeScript to know where the built versions of the code is.

If your package is published to NPM and you use PNPM you can use their publish config setting https://pnpm.io/package_json#publishconfig. Depending on your package manager and how you publish this approach will be different.

This will allow you to have one main entry when working in the repo, but have a different setup when the package is published.

{
name: '@my-company/lib-name,
version: '0.0.1',
main: 'dist/index.js'
}

7. Add project references between referenced libraries

To allow projects to reference each other, you need to add both the project reference for tsc in the project tsconfig.json and also add a path mapping in tsconfig.base.json.

Project tsconfig.json

"references": [
{ "path": "../my-library" }
]

Project package.json (if this project is published to NPM)

{
"dependencies": {
"@my-org/my-library": "0.0.1"
}
}

Root tsconfig.base.json

"paths": {
"@my-org/my-library": "./lib/my-library/src/index.ts"
}

We will be using https://www.npmjs.com/package/@changesets/cli to do our versioning and releasing of NPM packages. It needs the package.json dependency so it can effectively version the packages.

This can be done for public and private packages to get changelogs for all packages.

Application setup

Now you have a good library / test setup you need to get your applications working. I am not going to cover this in this blog post because there are too many options.

That said, I recommend you use a tool which uses ESBuild/SWC/Babel internally as none of these tools leverage the TypeScript compiler for compilation so they will resolve source files using path mappings, not project references and give you a good development experience

Wrapping up

TypeScript project references can be hard to get working with the rest of the JS Eco-system. We need to move away from the loaders/plugins which use the TypeScript compiler internally like ts-loader, ts-jest etc. They have done a great job making it easy to get started with TypeScript but as we move more towards mono repos they just slow down too much.

Without ts-jest / ts-loader I don’t think TypeScript would have the popularity it does now! Thanks to everyone who has created/maintained these tools over time, it’s a really complicated space and they have made it easy for us to get on with building applications.

Co-Founder featureboard.app | Principal Consultant arkahna.io | Previously Tech Lead Seven West Media WA | International Speaker | OSS | Mentor