TypeScript project references
The ultimate in-depth guide for using TypeScript in a monorepo effectively!
How to use TypeScript in a monorepo? What are project references? Why use project references? What is the best way to use project references? These are just a handful of questions that are constantly asked on Twitter, forums, Stack Overflow, and even your workplace.
Based on years of experience managing large-scale frontend repositories, we firmly believe that TypeScript project references are the proper solution for effectively scaling TypeScript in a monorepo. The official TypeScript documentation on project references answers many of these questions, but it basically boils down to the following:
- Project references enforce project boundaries, disallowing imports to arbitrary projects unless they have been referenced explicitly in configuration. This avoids circular references / cycles.
- It enables TypeScript to process individual units, instead of the entire repository as a whole. Perfect for reducing CI and local development times.
- It supports incremental compilation, so only out-of-date or affected projects are processed. The more TypeScript's cache is warmed, the faster it will be.
- It simulates how types work in the Node.js package ecosystem.
This all sounds amazing but there's got to be some downsides right? Unfortunately, there is:
- Project references require generating declarations to resolve type information correctly. This results in a lot of compilation artifacts littered throughout the repository. There are ways around this.
- This approach is a bit involved and may require some cognitive overhead based on your current level of TypeScript tooling knowledge.
If you'd like a real-world repository to reference, our moonrepo/moon, moonrepo/dev, and moonrepo/examples repositories utilizes this architecture!
Preface
Before you dive into this questionably long guide, we'd like to preface with:
- This guide is a living document and will continually be updated with best practices and frequently asked questions. Keep returning to learn more!
- This guide assumes a basic level knowledge of TypeScript and how it works.
- The architecture outlined in this guide assumes that TypeScript is only used for typechecking and not compiling. However, supporting compilation should be as easy as modifying a handful of compiler options.
- Although this guide exists within moon's documentation, it does not require moon. We've kept all implementation details generic enough for it be used in any repository, but have also included many notes on how moon would improve this experience.
Configuration
The most complicated part of integrating TypeScript in a monorepo is a proper configuration setup. Based on our extensive experience, we suggest the following architecture as a base! This is not perfect and can most definitely be expanded upon or modified to fit your needs.
Root-level
In a polyrepo, the root tsconfig.json
is typically the only configuration file, as it defines
common compiler options, and includes files to typecheck. In a monorepo, these responsibilities are
now split across multiple configuration files.
tsconfig.json
To start, the root tsconfig.json
file is nothing more than a list of all projects in the
monorepo, with each project being an individual entry in the references
field. Each entry must
contain a path
field with a relative file system path to the project root (that contains their
config).
We also do not define compiler options in this file, as project-level configuration files would
not be able to extend this file, as it would trigger a circular reference. Instead, we define
common compiler options in a root tsconfig.options.json
file, that this
file also extends
from.
In the end, this file should only contain 3 fields: extends
, files
(an empty list), and
references
. This abides the
official guidance around structure.
{
"extends": "./tsconfig.options.json",
"files": [],
"references": [
{
"path": "apps/foo"
},
{
"path": "packages/bar"
}
// ... more
]
}
When using moon, the
typescript.syncProjectReferences
setting will keep thisreferences
list automatically in sync, and the name of the file can be customized withtypescript.rootConfigFileName
.
tsconfig.options.json
This file will contain common compiler options that will be inherited by all projects in the monorepo. For project references to work correctly, the following settings must be enabled at the root, and typically should not be disabled in each project.
composite
- Enables project references and informs the TypeScript program where to find referenced outputs.declaration
- Project references rely on the compiled declarations (.d.ts
) of external projects. If declarations do not exist, TypeScript will generate them on demand.declarationMap
- Generate sourcemaps for declarations, so that language server integrations in editors like "Go to" resolve correctly.incremental
- Enables incremental compilation, greatly improving performance.noEmitOnError
- If the typechecker fails, avoid generating invalid or partial declarations.skipLibCheck
- Avoids eager loading and analyzing all declarations, greatly improving performance.
Furthermore, we have 2 settings that should be enabled per project, depending on the project type.
emitDeclarationOnly
- For packages: Emit declarations, as they're required for references, but avoid compiling to JavaScript.noEmit
- For applications: Don't emit declarations, as others should not be depending on the project.
For convenience, we provide the
tsconfig-moon
package, which
defines common compiler options and may be used here.
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"incremental": true,
"noEmitOnError": true,
"skipLibCheck": true
// ... others
}
}
When using moon, the name of the file can be customized with
typescript.rootOptionsConfigFileName
.
ECMAScript interoperability
ECMAScript modules (ESM) have been around for quite a while now, but the default TypeScript settings are not configured for them. We suggest the following compiler options if you want proper ESM support with interoperability with the ecosystem.
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"target": "esnext"
// ... others
}
}
.gitignore
Project references unfortunately generate a ton of artifacts that typically shouldn't be committed to the repository (but could be if you so choose). We suggest ignoring the following:
# The `outDir` for declarations
lib/
# Build cache manifests
*.tsbuildinfo
Project-level
Each project that contains TypeScript files and will utilize the typechecker must contain a
tsconfig.json
in the project root, typically as a sibling to package.json
.
tsconfig.json
A tsconfig.json
in the root of a project (application or package) is required, as it informs
TypeScript that this is a project, and that it can be referenced by other projects. In its simplest
form, this file should extend the root tsconfig.options.json
to inherit
common compiler options, define its own compiler options (below), define includes/excludes, and any
necessary references.
When using moon, the name of the file can be customized with
typescript.projectConfigFileName
.
- Applications
- Packages
For applications, declaration emitting can be disabled, since external projects should not be importing files from an application. If this use case ever arises, move those files into a package.
{
"extends": "../../../../tsconfig.options.json",
"compilerOptions": {
"noEmit": true
},
"include": [],
"references": []
}
For packages, we must define the location in which to generate declarations. These are the declarations that external projects would reference. This location is typically gitignored!
{
"extends": "../../../../tsconfig.options.json",
"compilerOptions": {
"emitDeclarationOnly": true,
"outDir": "./lib"
},
"include": [],
"references": []
}
When using moon, the
outDir
can automatically be re-routed to a shared cache usingtypescript.routeOutDirToCache
, to avoid littering the repository with compilation artifacts.
Includes and excludes
Based on experience, we suggest defining include
instead of exclude
, as managing a whitelist of
typecheckable files is much easier. When dealing with excludes, there are far too many
possibilities. To start, you have node_modules
, and for applications maybe dist
, build
,
.next
, or another application specific folder, and then for packages you may have lib
, cjs
,
esm
, etc. It becomes very... tedious.
The other benefit of using include
is that it forces TypeScript to only load what's necessary,
instead of eager loading everything into memory, and for typechecking files that aren't part of
source, like configuration.
{
// ...
"include": ["src/**/*", "tests/**/*", "*.js", "*.ts"]
}
Depending on other projects
When a project depends on another project (by importing code from it), either using relative paths,
path aliases, or its package.json
name, it must be declared as a
reference. If not declared, TypeScript will error with a message about importing outside the project
boundary.
{
// ...
"references": [
{
"path": "../../foo"
},
{
"path": "../../bar"
},
{
"path": "../../../../baz"
}
]
}
To make use of editor intellisense and auto-imports of deeply nested files, you'll most likely need to add includes for referenced projects as well.
{
// ...
"include": [
// ...
"src/**/*",
"../../foo/src/**/*",
"../../bar/src/**/*",
"../../../../baz/src/**/*"
]
}
When using moon, the
typescript.syncProjectReferences
setting will keep thisreferences
list automatically in sync, andtypescript.includeProjectReferenceSources
forinclude
.
tsconfig.*.json
Additional configurations may exist in a project that serve a role outside of typechecking, with one
such role being npm package publishing. These configs are sometimes named tsconfig.build.json
,
tsconfig.types.json
, or tsconfig.lib.json
. Regardless of what they're called, these configs are
optional, so unless you have a business need for them, you may skip this section.
Package publishing
As mentioned previously, these configs may be used for npm packages, primarily for generating
TypeScript declarations that are mapped through the package.json
types
(or typings
) field.
Given this package.json
...
{
// ...
"types": "./lib/index.d.ts"
}
Our tsconfig.build.json
may look like...
{
"extends": "../../../../tsconfig.options.json",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": ["src/**/*"]
}
Simple right? But why do we need an additional configuration? Why not use the other tsconfig.json
?
Great questions! The major reason is that we only want to publish declarations for source files,
and the declarations file structure should match 1:1 with the sources structure. The tsconfig.json
does not guarantee this, as it may include test, config, or arbitrary files, all of which may not
exist in the sources directory (src
), and will alter the output to an incorrect directory
structure. Our tsconfig.build.json
solves this problem by only including source files, and by
forcing the source root to src
using the rootDir
compiler option.
However, there is a giant caveat with this approach! Because TypeScript utilizes Node.js's module
resolution, it will reference the declarations defined by the package.json
types
or
exports
fields, instead of the outDir
compiler option, and
the other tsconfig.json
does not guarantee these files will exist. This results in TypeScript
failing to find the appropriate types! To solve this, add the tsconfig.build.json
as a project
reference to tsconfig.json
.
{
// ...
"references": [
{
"path": "./tsconfig.build.json"
}
// ... others
]
}
Vendor specific
Some vendors, like Vite, Vitest, and
Astro may include additional tsconfig.*.json
files unique to their ecosystem.
We suggest following their guidelines and implementation when applicable.
Running the typechecker
Now that our configuration is place, we can run the typechecker, or attempt to at least! This can be
done with the tsc --build
command, which acts as a
build orchestrator.
We also suggest passing --verbose
for insights into what projects are compiling, and which are
out-of-date.
On all projects
From the root of the repository, run tsc --build --verbose
to typecheck all projects, as defined
in tsconfig.json. TypeScript will generate a directed acyclic graph (DAG) and
compile projects in order so that dependencies and references are resolved correctly.
Why run TypeScript in the root? Typically you would only want to run against projects, but for
situations where you need to verify that all projects still work, running in the root is the best
approach. Some such situations are upgrading TypeScript itself, upgrading global @types
packages,
updating shared types, reworking build processes, and more.
On an individual project
To only typecheck a single project (and its dependencies), there are 2 approaches. The first is to
run from the root, and pass a relative path to the project, such as
tsc --build --verbose packages/foo
. The second is to change the working directory to the project,
and run from there, such as cd packages/foo && tsc --build --verbose
.
Both approaches are viable, and either may be used based on your tooling, build system, task runner,
so on and so forth. This is the approach moon suggests with its
typecheck
task.
On affected projects
In CI environments, it's nice to only run the typechecker on affected projects — projects that
have changed files. While this isn't entirely possible with tsc
, it is possible with moon! Head
over to the
official docs for more information.
Using paths
aliases
Path aliases, also known as path mapping or magic imports, is the concept of defining an import
alias that re-maps its underlying location on the file system. In TypeScript, this is achieved with
the
paths
compiler option.
In a monorepo world, we suggest using path aliases on a per-project basis, instead of defining them
"globally" in the root. This gives projects full control of what's available and what they want to
import, and also plays nice with the mandatory baseUrl
compiler option.
{
// ...
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
// Within the project
":components/*": ["./src/components/*"],
// To a referenced project
":shared/*": ["../../shared/code/*"]
}
},
"references": [
{
"path": "../../shared/code"
}
]
}
The above aliases would be imported like the following:
// Before
import { Button } from '../../../../components/Button';
import utils from '../../shared/code/utils';
// After
import { Button } from ':components/Button';
import utils from ':shared/utils';
When using path aliases, we suggest prefixing or suffixing the alias with :
so that it's apparent
that it's an alias (this also matches the new node:
import syntax). Using no special character or
@
is problematic as it risks a chance of collision with a public npm package and may accidentally
open your repository to a
supply chain attack. Other
characters like ~
and $
have an existing meaning in the ecosystem, so it's best to avoid them
aswell.
Importing source files from local packages
If you are importing from a project reference using a package.json
name, then TypeScript will
abide by Node.js module resolution logic, and will import using the
main
/types
or exports
entry points.
This means that you're importing compiled code instead of source code, and will require the
package to be constantly rebuilt if changes are made to it.
However, why not simply import source files instead? With path aliases, you can do just that, by
defining a paths
alias that maps the package.json
name to its source files, like so.
{
// ...
"compilerOptions": {
// ...
"paths": {
// Index import
"@scope/name": ["../../shared/package/src/index.ts"],
// Deep imports
"@scope/name/*": ["../../shared/package/src/*"]
}
},
"references": [
{
"path": "../../shared/package"
}
]
}
When using moon, the
typescript.syncProjectReferencesToPaths
setting will automatically createpaths
based on the local references.
Sharing and augmenting types
Declaring global types, augmenting node modules, and sharing reusable types is a common practice. There are many ways to achieve this, so choose what works best for your repository. We use the following pattern with great success.
At the root of the repository, create a types
folder as a sibling to tsconfig.json
. This folder
must only contain declarations (.d.ts
) files for the following reasons:
- Declarations can be
include
ed in a project without having to be a project reference. - Hard-coded declarations do not need to be compiled from TypeScript files.
Based on the above, update your project's tsconfig.json
to include all of these types, or just
some of these types.
{
// ...
"include": ["src/**/*", "../../../../types/**/*"]
}
In the future, moon will provide a setting to automate this workflow!
Supporting package.json
exports
In Node.js v12, they introduced a new field to package.json
called exports
that aims to solve
the shortcomings of the main
field. The exports
field is very complicated, and instead of
repeating all of its implementation details, we suggest reading
the official Node.js docs on this topic.
With that being said, TypeScript completely ignored the exports
field until
v4.7, and
respecting exports
is still ignored unless the moduleResolution
compiler option is set to
"nodenext", "node16", or "bundler". If moduleResolution
is set to "node", then your integration is
resolving based on the main
and types
field, which are basically "legacy".
Enabling package.json
imports/exports resolution is very complicated, and may be very tedious,
especially considering the state of the npm ecosystem. Proceed with caution!
State of the npm ecosystem
As mentioned above, the npm ecosystem (as of November 2022) is in a very fragile state in regards to imports/exports. Based on our experience attempting to utilize them in a monorepo, we ran into an array of problems, some of which are:
- Published packages are simply utilizing imports/exports incorrectly. The semantics around CJS/ESM
are very strict, and they may be configured wrong. This is exacerbated by the new
type
field. - The
exports
field overrides themain
andtypes
fields. Ifexports
exists without type conditions, but thetypes
field exists, thetypes
entry point is completely ignored, resulting in TypeScript failures.
With that being said, there are ways around this and moving forward is possible, if you dare!
Enabling imports/exports resolution
To start, set the moduleResolution
compiler option to "nodenext" (for packages) or "bundler" (for
apps) in the tsconfig.options.json
file.
{
"compilerOptions": {
// ...
"moduleResolution": "nodenext"
}
}
Next, run the typechecker from the root against all projects. This will help uncover all potential issues with the dependencies you're using or the current configuration architecture. If no errors are found, well congratulations, otherwise jump to the next section for more information on resolving them.
If you're trying to use exports
in your own packages, ensure that the types
condition is set,
and it's the first condition in the mapping! We also suggest including main
and the top-level
types
for tooling that do not support exports
yet.
{
// ...
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./lib/index.d.ts",
"node": "./lib/index.js"
}
}
}
Managing exports
is non-trivial. If you'd prefer them to be automatically generated based on a set
of inputs, we suggest using Packemon!
Resolving issues
There's only one way to resolve issues around incorrectly published exports
, and that is package
patching, either with Yarn's patching feature,
pnpm's patching feature, or the
patch-package
package. With patching, you can:
- Inject the
types
condition/field if it's missing. - Re-structure the
exports
mapping if it's incorrect. - Fix incorrect entry point paths.
- And even fix invalid TypeScript declarations or JavaScript code!
{
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"exports": {
"./package.json": "./package.json",
- ".": "./lib/index.js"
+ ".": {
+ "types": "./lib/index.d.ts",
+ "node": "./lib/index.js"
+ }
}
}
More often than not, the owners of these packages may be unaware that their exports
mapping is
incorrect. Why not be a good member of the community and report an issue or even submit a pull
request?
Editor integration
Unfortunately, we only have experience with VS Code. If you prefer another editor and have guidance you'd like to share with the community, feel free to submit a pull request and we'll include it below!
VS Code
VS Code has first-class support for TypeScript and project references, and should "just work" without any configuration. You can verify this by restarting the TypeScript server in VS Code (with the cmd + shift + p command palette) and navigating to each project. Pay attention to the status bar at the bottom, as you'll see this:
When this status appears, it means that VS Code is compiling a project. It will re-appear multiple times, basically for each project, instead of once for the entire repository.
Furthermore, ensure that VS Code is using the version of TypeScript from the typescript
package in
node_modules
. Relying on the version that ships with VS Code may result in unexpected TypeScript
failures.
{
"typescript.tsdk": "node_modules/typescript/lib"
// Or "Select TypeScript version" from the command palette
}
FAQ
I still have questions, where can I ask them?
We'd love to answer your questions and help anyway that we can. Feel free to...
- Join the moonrepo discord and post your question in the
#typescript
channel. - Ping me, Miles Johnson, on Twitter. I'll try my best to respond to every tweet.
Do I have to use project references?
Short answer, no. If you have less than say 10 projects, references may be overkill. If your repository is primarily an application, but then has a handful of shared npm packages, references may also be unnecessary here. In the end, it really depends on how many projects exist in the monorepo, and what your team/company is comfortable with.
However, we do suggest using project references for very large monorepos (think 100s of projects), or repositories with a large number of contributors, or if you merely want to reduce CI typechecking times.
What about not using project references and only using source files?
A popular alternative to project references is to simply use the source files as-is, by updating the
main
and types
entry fields within each package.json
to point to the original TypeScript
files. This approach is also known as "internal packages".
{
// ...
"main": "./src/index.tsx",
"types": "./src/index.tsx"
}
While this works, there are some downsides to this approach.
- Loading declaration files are much faster than source files.
- You'll lose all the benefits of TypeScript's incremental caching and compilation. TypeScript will consistently load, parse, and evaluate these source files every time. This is especially true for CI environments.
- When using
package.json
workspaces, bundlers and other tools may consider these source files "external" as they're found innode_modules
. This will require custom configuration to allow it. - It breaks consistency. Consistency with the npm ecosystem, and consistency with how packaging and TypeScript was designed to work. If all packages are internal, then great, but if you have some packages that are published, you now have 2 distinct patterns for "using packages" instead of 1.
With that being said, theres a 3rd alternative that may be the best of both worlds, using project
references and source files,
by using paths
aliases.
All in all, this is a viable approach if you're comfortable with the downsides listed above. Use the pattern that works best for your repository, team, or company!
How to integrate with ESLint?
We initially included ESLint integration in this guide, but it was very complex and in-depth on its own, so we've opted to push it to another guide. Unfortunately, that guide is not yet available, so please come back soon! We'll announce when it's ready.
How to handle circular references?
Project references do not support circular references (cycles), which is great, as they are a code smell! If you find yourself arbitrarily importing code from random sources, or between 2 projects that depend on each other, then this highlights a problem with your architecture. Projects should be encapsulated and isolated from outside sources, unless explicitly allowed through a dependency. Dependencies are "upstream", so having them depend on the current project (the "downstream"), makes little to no sense.
If you're trying to adopt project references and are unfortunately hitting the circular reference problem, don't fret, untangling is possible, although non-trivial depending on the size of your repository. It basically boils down to creating an additional project to move coupled code to.
For example, if project A was importing from project B, and B from A, then the solution would be to create another project, C (typically a shared npm package), and move both pieces of code into C. A and B would then import from C, instead of from each other. We're not aware of any tools that would automate this, or detect cycles, so you'll need to do it manually.