Project References & Monorepos
Small projects have one tsconfig.json and one src folder. Companies often keep many related pieces in one repository: a web app, an admin panel, an API, and shared code such as types and helpers. This layout is called a monorepo. With one giant tsconfig.json, every change rechecks everything, and nothing stops the web app from importing a private file deep inside the API.
TypeScript's answer is project references: you split the code into small projects, each with its own config, and declare which projects depend on which. tsc --build then compiles them in the right order and skips the ones that have not changed. This lesson builds a small school ERP monorepo step by step.
The monorepo layout
Our repository has a shared package with types and helpers, and an API app that uses it:
school-erp/
package.json (npm workspaces)
tsconfig.base.json (shared compiler settings)
tsconfig.json (the "solution": lists all projects)
packages/shared/
package.json (name: @erp/shared)
tsconfig.json
src/index.ts
apps/api/
package.json (depends on @erp/shared)
tsconfig.json
src/server.tsThe root package.json turns the folders into workspaces, so npm links @erp/shared into node_modules and the API can import it by name, like any package from npm:
{
"name": "school-erp",
"private": true,
"type": "module",
"workspaces": ["packages/*", "apps/*"]
}pnpm and Yarn have the same feature with slightly different files (pnpm uses pnpm-workspace.yaml). The TypeScript part below is identical whichever you use.
The shared package
The shared code is ordinary TypeScript. It is small here, but in real repos this is where API types, validation schemas and formatting helpers live, so the web app and the API never disagree:
export type Student = { rollNo: number; name: string; feesDue: number };
export function formatRupees(amount: number): string {
return "₹" + amount.toLocaleString("en-IN");
}
console.log(formatRupees(12500));
console.log(formatRupees(1250000)); // Indian grouping: lakhs₹12,500 ₹12,50,000
Its package.json points other packages at the compiled output, with the types condition first (as in Module Augmentation & Publishing Types):
{
"name": "@erp/shared",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
}composite and references
Every project that others depend on needs "composite": true. This option makes TypeScript write declaration files, record what it built in a .tsbuildinfo file, and insist that all source files are listed through include or files. Put the common settings in a base file:
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": []
}
}Each project extends the base and sets its own folders. The API also lists the projects it depends on in references:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "./src", "outDir": "./dist" },
"include": ["src"],
"references": [{ "path": "../../packages/shared" }]
}Finally, the root tsconfig.json is a solution file: it compiles no files itself ("files": []) and only references every project. Editors and tsc -b use it to find the whole repository:
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./apps/api" }
]
}Building with tsc -b
tsc --build (short: tsc -b) reads the references, builds dependencies first, and remembers what it built. The --verbose flag explains each decision:
npx tsc -b --verbose03:40:11 AM - Projects in this build:
* packages/shared/tsconfig.json
* apps/api/tsconfig.json
* tsconfig.json
03:40:11 AM - Project 'packages/shared/tsconfig.json' is out of date because output file 'packages/shared/tsconfig.tsbuildinfo' does not exist
03:40:11 AM - Building project 'packages/shared/tsconfig.json'...
03:40:16 AM - Project 'apps/api/tsconfig.json' is out of date because output file 'apps/api/tsconfig.tsbuildinfo' does not exist
03:40:16 AM - Building project 'apps/api/tsconfig.json'...Run it again without changing anything and both projects are skipped as "up to date". Change only the API and shared is not rebuilt; the API reads shared's .d.ts files instead of re-checking its source. In a repository with dozens of packages, this is the difference between a two-second and a two-minute check.
Useful build commands:
tsc -b --watch: rebuild affected projects when files change.tsc -b --clean: delete the outputs of every project.tsc -b --builders 4: in TypeScript 7, build up to four independent projects at the same time (see TypeScript 7 Compiler & Upgrading).
Then run the compiled API with Node.js as usual, for example node apps/api/dist/server.js.
Errors that keep boundaries honest
References are also a guard rail. If the shared project forgets composite, the build stops immediately:
apps/api/tsconfig.json(5,18): error TS6306: Referenced project 'C:/projects/school-erp/packages/shared' must have setting "composite": true.
And if someone takes a shortcut and imports a source file from another project by relative path, instead of going through the package name, TypeScript refuses:
apps/api/src/server.ts(1,44): error TS6059: File 'C:/projects/school-erp/packages/shared/src/index.ts' is not under 'rootDir' 'C:/projects/school-erp/apps/api/src'. 'rootDir' is expected to contain all source files. apps/api/src/server.ts(1,44): error TS6307: File 'C:/projects/school-erp/packages/shared/src/index.ts' is not listed within the file list of project 'C:/projects/school-erp/apps/api/tsconfig.json'. Projects must list all files or use an 'include' pattern.
(The real messages include an extra line explaining that the file is an ES module; it is left out here.) The fix is to import from "@erp/shared". This is exactly the discipline large teams want: each package exposes a public API through its exports, and nobody reaches into another package's internals.
Choices you will see in real repositories
- Built packages (this lesson). Shared packages are compiled to
distand consumed through.d.tsfiles. Most predictable, works with Node.js, and makestsc -bfast. - Source-only internal packages. In apps built entirely by a bundler such as Vite, some teams point
exportsstraight atsrc/index.tsand skip the build step for shared code. Simpler, but every app re-checks the shared source, and Node.js cannot run it without type stripping. - Task runners. Tools such as Turborepo and Nx sit on top of workspaces. They run scripts (build, test, lint) across packages in dependency order and cache the results. They do not replace
tsconfig.jsonreferences; they calltscfor you.
Common mistakes
- Forgetting
compositein a referenced project. The build stops with TS6306. - Importing across packages with
../../. Use the package name so references andexportsare respected. - Running plain
tscinstead oftsc -bin the root. Without-b, references are not built in order and the solution file compiles nothing. - Committing
distand.tsbuildinfofiles. Add them to.gitignore; they are build outputs. - Expecting
referencesto be inherited throughextends. It never is; list references in each project's own config.
Key points
- A monorepo keeps related apps and packages in one repository; npm, pnpm or Yarn workspaces link them by package name.
- A project that others depend on needs
"composite": true; dependents list it underreferences. - A root solution
tsconfig.jsonwith"files": []andreferencesties all projects together. tsc -bbuilds in dependency order, skips up-to-date projects and uses.tsbuildinfofiles to stay incremental.- Project references enforce boundaries: importing another project's source files directly is an error.
Exercise
Add a third project, apps/admin, to the school ERP repository. It should depend on @erp/shared, print the total fees due for a list of students using formatRupees, and be part of the solution file. Write its package.json, tsconfig.json and src/report.ts, and list the change you need in the root tsconfig.json.
Show solution
{ "name": "@erp/admin", "type": "module", "dependencies": { "@erp/shared": "*" } }{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "./src", "outDir": "./dist" },
"include": ["src"],
"references": [{ "path": "../../packages/shared" }]
}// apps/admin/src/report.ts
import { formatRupees, type Student } from "@erp/shared";
const students: Student[] = [
{ rollNo: 1, name: "Ananya", feesDue: 12500 },
{ rollNo: 2, name: "Farhan", feesDue: 0 },
{ rollNo: 3, name: "Isha", feesDue: 8000 },
];
const total = students.reduce((sum, s) => sum + s.feesDue, 0);
console.log(`Total due: ${formatRupees(total)}`); // Total due: ₹20,500Add { "path": "./apps/admin" } to the root references list, run npm install once so the workspace link exists, then npx tsc -b builds shared (if needed) before admin.
Quick Quiz
1. What does "composite": true require and enable?
Composite projects can be referenced and built incrementally.
2. What does the root tsconfig.json with "files": [] and references do?
It compiles nothing itself and only lists the projects.
3. Why is import { formatRupees } from "../../../packages/shared/src/index.js" an error in the API project?
Import shared code by its package name so the reference and its declarations are used.
4. Which command builds referenced projects in dependency order and skips unchanged ones?
Build mode reads references and uses .tsbuildinfo files to stay incremental.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
