EthicsComputerCourses
Join training +91 90532 10052

Project References & Monorepos

TypeScript · Lesson 38 of 42 Advanced 8 min read Updated

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:

Text
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.ts

The 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:

JSON
{
  "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:

TypeScriptRun it yourself
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
Output
₹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):

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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:

Terminal
npx tsc -b --verbose
Output
03: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:

Output
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:

Output
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 dist and consumed through .d.ts files. Most predictable, works with Node.js, and makes tsc -b fast.
  • Source-only internal packages. In apps built entirely by a bundler such as Vite, some teams point exports straight at src/index.ts and 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.json references; they call tsc for you.

Common mistakes

  • Forgetting composite in a referenced project. The build stops with TS6306.
  • Importing across packages with ../../. Use the package name so references and exports are respected.
  • Running plain tsc instead of tsc -b in the root. Without -b, references are not built in order and the solution file compiles nothing.
  • Committing dist and .tsbuildinfo files. Add them to .gitignore; they are build outputs.
  • Expecting references to be inherited through extends. 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 under references.
  • A root solution tsconfig.json with "files": [] and references ties all projects together.
  • tsc -b builds in dependency order, skips up-to-date projects and uses .tsbuildinfo files 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
JSON
{ "name": "@erp/admin", "type": "module", "dependencies": { "@erp/shared": "*" } }
JSON
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "rootDir": "./src", "outDir": "./dist" },
  "include": ["src"],
  "references": [{ "path": "../../packages/shared" }]
}
TypeScript
// 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,500

Add { "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?

2. What does the root tsconfig.json with "files": [] and references do?

3. Why is import { formatRupees } from "../../../packages/shared/src/index.js" an error in the API project?

4. Which command builds referenced projects in dependency order and skips unchanged ones?

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