Topic 12.4
Project References and tsc --build
In one line
Project references let you split a big codebase into smaller TypeScript projects that build in dependency order and rebuild only what changed. tsc --build and the .tsbuildinfo file are how it knows what to skip.
Think of it like this
A big restaurant kitchen has stations: sauces, bread, desserts. If only the dessert recipe changes, you don't remake the sauces. Each station keeps a written label saying "made on Tuesday from recipe version 3". The head chef reads the labels and re-cooks only what is out of date, in the right order: bread before sandwiches. Project references are those stations; the .tsbuildinfo file is the label.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Monorepo
- One repository that holds several packages or apps.
- Project reference
- An entry in references that says this project depends on another TypeScript project.
- composite
- A setting that makes a project referenceable: declarations on, all files listed, incremental on.
- tsc --build / -b
- Builds a project and everything it references in dependency order, skipping what is up to date.
- .tsbuildinfo
- A file where tsc stores what it knew at the last build: file list, hashes and declaration shapes. Used to skip work.
- Incremental build
- A build that redoes only what changed since last time.
- Declaration file (.d.ts)
- A file with only the types of a module and no code. Referenced projects are checked against these.
- Builder / checker
- In TypeScript 7.0, a builder is a project being built in parallel and a checker is a worker that type-checks inside one project.
- isolatedDeclarations
- A flag that requires explicit types on exports, so declaration files can be written one file at a time.
Step by step
01Two packages, one root
Here is a tiny monorepo: packages/core exports a Money type and a format function; packages/app uses them. A root tsconfig.json has files: [] and lists both as references. A shared base config sets composite, declaration, strict and the rest.
// tsconfig.base.json
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true
}
}
// packages/app/tsconfig.json (core's is the same without "references")
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
"include": ["src"],
"references": [{ "path": "../core" }]
}
// tsconfig.json (root: builds nothing itself)
{
"files": [],
"references": [{ "path": "packages/core" }, { "path": "packages/app" }]
}02Build only the app, and see why it needs core first
tsc -p packages/app checks one project. app imports from core, but core has never been built, so its declaration file doesn't exist.
$ tsc -p packages/app
packages/app/src/main.ts(1,36): error TS6305: Output file '.../packages/core/dist/index.d.ts' has not been built from source file '.../packages/core/src/index.ts'.03tsc --build: dependencies first
tsc -b at the root builds core, then app. Afterwards each project has its own dist and tsconfig.tsbuildinfo, and core/dist/index.d.ts is the contract app sees: types only, no code.
$ tsc -b
$ find packages -type f -not -path "*/src/*" | sort
packages/app/dist/main.d.ts
packages/app/dist/main.d.ts.map
packages/app/dist/main.js
packages/app/tsconfig.json
packages/app/tsconfig.tsbuildinfo
packages/core/dist/index.d.ts
packages/core/dist/index.d.ts.map
packages/core/dist/index.js
packages/core/tsconfig.json
packages/core/tsconfig.tsbuildinfo
$ cat packages/core/dist/index.d.ts
export interface Money {
amount: number;
currency: "INR" | "USD";
}
export declare function format(m: Money): string;
//# sourceMappingURL=index.d.ts.map
$ head -c 160 packages/core/tsconfig.tsbuildinfo
{"version":"7.0.2","root":[64],"packageJsons":["../../../../../package.json"],"fileNames":["lib.es5.d.ts","lib.es2015.d.ts", ...04Nothing changed: up to date
Run it again with --verbose. For each project the compiler compares the newest input with the .tsbuildinfo and reports the reason. (Times are shown as the tool prints them.)
$ tsc -b --verbose
02:36:29 PM - Projects in this build:
* packages/core/tsconfig.json
* packages/app/tsconfig.json
* tsconfig.json
02:36:29 PM - Project 'packages/core/tsconfig.json' is up to date because newest input 'packages/core/src/index.ts' is older than output 'packages/core/tsconfig.tsbuildinfo'
02:36:29 PM - Project 'packages/app/tsconfig.json' is up to date because newest input 'packages/app/src/main.ts' is older than output 'packages/app/tsconfig.tsbuildinfo'05Change something private, then something public
First append a comment to core's file. core is rebuilt, but its declaration output is identical, so app is only touched: "up to date with .d.ts files from its dependencies" and its timestamps are updated.
Then add "EUR" to the currency type, which changes the public declaration. Now app is out of date because core/dist/index.d.ts is newer than its .tsbuildinfo, and it is rebuilt.
# a comment appended to core/src/index.ts
$ tsc -b -v
... Project 'packages/core/tsconfig.json' is out of date because output 'packages/core/tsconfig.tsbuildinfo' is older than input 'packages/core/src/index.ts'
... Building project 'packages/core/tsconfig.json'...
... Project 'packages/app/tsconfig.json' is up to date with .d.ts files from its dependencies
... Updating output timestamps of project 'packages/app/tsconfig.json'...
# "INR" | "USD" becomes "INR" | "USD" | "EUR"
$ tsc -b -v
... Building project 'packages/core/tsconfig.json'...
... Project 'packages/app/tsconfig.json' is out of date because output 'packages/app/tsconfig.tsbuildinfo' is older than input 'packages/core/dist/index.d.ts'
... Building project 'packages/app/tsconfig.json'...06A type error in a dependency
Add export const x: number = "s"; to core. With default settings tsc -b reports the error, still writes core's output, and then builds app too (exit code 2). If you also set noEmitOnError: true, core writes no declarations, so app fails with TS6305: an error in a dependency stops everything that depends on it.
$ tsc -b -v
... Building project 'packages/core/tsconfig.json'...
packages/core/src/index.ts(10,14): error TS2322: Type 'string' is not assignable to type 'number'.
... Building project 'packages/app/tsconfig.json'...
# with "noEmitOnError": true in the base config
$ tsc -b -v
... Building project 'packages/core/tsconfig.json'...
packages/core/src/index.ts(10,14): error TS2322: Type 'string' is not assignable to type 'number'.
... Building project 'packages/app/tsconfig.json'...
packages/app/src/main.ts(1,36): error TS6305: Output file '.../packages/core/dist/index.d.ts' has not been built from source file '.../packages/core/src/index.ts'.07TypeScript 7: parallel flags
--builders and --checkers take a number. Defaults are chosen by the compiler (4 checkers per project). --force rebuilds everything, which is how you compare. The flags are listed by tsc --help --all.
$ tsc -b --builders 2 --checkers 2 --force # exit 0 on this tiny repo
$ tsc --help --all
...
--checkers
Set the number of checkers per project.
--singleThreaded
Run in single threaded mode.
--builders
Set the number of projects to build concurrently.Try it yourself
- 1
Predict the rebuild
In a two-project monorepo, you rename a local variable inside a function body in
core. Isappre-checked? No:core's.d.tsoutput is unchanged, soappis only touched. You change a function's return type:appis rebuilt. - 2
Read the label
Build a project, then open its
tsconfig.tsbuildinfoand find"version":"7.0.2"at the start. Why does the compiler store its own version in it? Because the info is only valid for the compiler that wrote it; a different version rebuilds from scratch. - 3
Fix one inferred export
In the first live example, add
: number[]tosizesonly. One error disappears and the one inBoxstays. Which line number is left? Line 7, column 11.
Code & diagrams
A declaration file has to be written for every export. label and retries are easy: the return type is written, and 3 is a literal. For sizes and Box.items the type is inferred from an array, which a tool looking at only this file could get wrong, so it asks you to write it. Without the override this example is clean.
Console
(nothing was printed)Expected console output
item: a 3 3 3Two annotations and the errors are gone. This is the price of fast declaration emit in a monorepo: the public surface of each package is written down, which is also good documentation. The same file in both examples prints the same line, since annotations are erased.
Console
(nothing was printed)Expected console output
item: a 3 3 3Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Build the app without building core
Delete the dist folders and .tsbuildinfo files, then run tsc -p packages/app instead of tsc -b.
Break #2
Export an inferred array
With isolatedDeclarations on, write export const sizes = [1, 2, 3]; without a type.
Myth vs fact
Myth
Project references make tsc faster by themselves.
Fact
They make it faster for rebuilds, because unchanged projects are skipped, and they let dependants use small .d.ts files. A cold build of everything still does all the work (7.0's speed comes from the native compiler and parallelism).
Myth
tsc -p packages/app builds its references too.
Fact
Only tsc -b follows references and builds them. -p checks one project against already-built declarations.
Myth
The .tsbuildinfo file is a build artefact you should commit.
Fact
It is a cache. Add it, and the dist folders, to .gitignore. A missing or old one only costs a full rebuild.
Myth
isolatedDeclarations changes the JavaScript or the type-checking of your code.
Fact
It adds errors that ask you for explicit types on exports. Nothing about the emitted JavaScript changes.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
In real monorepos, packages import each other by package name (
import { format } from "@acme/core"), resolved through the package manager's workspaces and theexports/typesfields inpackage.json. TypeScript then maps that import to the referenced project's output. The tiny example here uses a relative path to keep the folders short; that path would not work at runtime fromdist, which is exactly why real projects use package names. - ▸
compositeprojects must list all their source files. A file that is not matched byincludeorfilesis an error, which prevents a project from silently depending on files outside its boundary. KeeprootDirandoutDirexplicit in every package so outputs are predictable. - ▸
A change in a declaration is what invalidates dependants, so declaration emit is on the critical path of a big build. That is why
isolatedDeclarationsexists: with every export typed, a fast tool can write the.d.tsfile for each source file separately and in parallel, without running the whole type checker first. - ▸
Build orchestration order matters when you have a cycle: references can't form a cycle, and the compiler will tell you. Split the shared types into a third project that both depend on.
Remember this
- 1
The problem this solves: in a big codebase, re-checking every file after every small edit is slow, and nothing stops one part from reaching into another's internals. Project references split the code into smaller projects that rebuild only when something they depend on changes. One
tsconfig.jsonthat covers an entire monorepo (a single repository holding several packages) means one huge program: every edit rechecks everything, and any file can import any other, so boundaries blur. Project references split it up. Each package gets its owntsconfig.json; a package that uses another lists it under **references**; and a root config withfiles: []just points at them all, so the whole tree can be built with one command. - 2
A project that others depend on must be **
composite: true**. That setting makes the project promise to be self-contained and re-buildable: it requiresdeclaration(so.d.tsfiles exist; turning it off is errorTS6304), requires that every source file is matched byincludeorfiles(an imported file outside the list isTS6307), and turns on incremental mode, which writes a **.tsbuildinfo** file. That file records the project's files, their hashes and the shape of the declarations that came out. The next build reads it to decide what changed. - 3
A project that uses another project does not read its source. It reads its **
.d.tsoutput** (declaration files: types only, no code, Topic 11.3). That is the big win: to checkapp, the compiler needs only the (small) declaration files ofcore, not a full re-check ofcore's code. It also enforces a boundary:appcan only see whatcoreexports. And it is the reason the.d.tsfiles must exist before dependants are checked, which is what--buildtakes care of. - 4
**
tsc --build** (ortsc -b) is a build orchestrator, not just a compiler run. It reads thereferencesgraph, sorts the projects so dependencies build first, and for each project asks: is the output older than any input, or than a dependency's.d.ts? If not, skip it ("up to date"). If only a comment or private code incorechanged, the.d.tsoutput is the same, soappis only touched (timestamps updated), not re-checked. If you changecore's public types,appis rebuilt.--verbosetells you the reason for every decision,--forcerebuilds everything and--cleandeletes the outputs. - 5
Why not just use
tsc -p packages/app? Because it cannot buildcorefor you. Ifcore's.d.tsfiles don't exist yet,appfails withTS6305: Output file ... has not been built from source file .... Runtsc -bto build the referenced projects first. Editors have the same dependency: the language server uses the built declarations of referenced projects, so build once after cloning. - 6
TypeScript 7.0 builds projects in parallel. **
--builders <n>is how many projects may build at the same time,--checkers <n>is how many checker workers each project uses (default 4), and--singleThreaded** turns it all off. More workers help on big graphs and machines with many cores, and use more memory; on a laptop with little memory fewer is safer. These flags are new in 7.0 and don't exist in 6.0. - 7
Related feature: **
isolatedDeclarations** (TypeScript 5.5). It requires every exported function, variable and class member to carry an explicit type, so a tool can write the.d.tsfile by looking at that one file alone, without running the whole checker. It trades a bit of typing for much faster declaration emit in large monorepos, and is what makes it possible for fast tools to produce.d.tsfiles. The live examples below show both sides.
Explain it without notes
What problem do project references solve?
What does composite: true do?
What is tsc --build doing beyond running the compiler?
What is a .tsbuildinfo file, and should you commit it?
What is isolatedDeclarations for?
Practice
Write the root tsconfig.json and the references entry so that packages/web depends on packages/shared. Which settings must shared have?
Predict what tsc -b -v says after you change only a function body (not its signature) inside shared, and after you change a parameter type.
Trade-offs
- ↔
References give faster rebuilds, enforced boundaries and parallel builds, but every package needs a config, a build step and sometimes explicit types on exports. For a single app with a few thousand files, one tsconfig is simpler and often fast enough, especially on TypeScript 7.
- ↔
isolatedDeclarationsspeeds up declaration emit but spreads annotations through your code. It pays off in large libraries and monorepos, not in a small app.
Done when you can
I can describe a monorepo layout with a root config, a base config and per-package configs.
I can explain what
compositeandreferencesdo.I can explain why
tsc -pfails withTS6305and whattsc -bdoes instead.I can read
tsc -b --verboseto see why a project was or wasn't rebuilt.I know what
.tsbuildinfois and that it is not committed.I can name
--builders,--checkersand--singleThreadedas TypeScript 7.0 flags.