Monorepos & Project References
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
Monorepos let several packages live in one repository. Project references let TypeScript understand those packages as separate build units instead of one giant pile of files. Together, they make shared typed code feel local while still behaving like real packages.
This lesson is tool-shaped. The examples are read-only package and tsconfig files for a small workspace repo. No installs, no generated output, and no fake build are needed here.
Workspaces: local packages with real names
A workspace is a package-manager feature. In npm, the root package.json declares which folders are packages, and npm install links those packages into the root node_modules so they can be consumed by their package names.
Reference layout:
pricing-platform/
package.json
tsconfig.json
tsconfig.base.json
packages/
contracts/
package.json
tsconfig.json
src/index.ts
pricing-core/
package.json
tsconfig.json
src/index.ts
dashboard/
package.json
tsconfig.json
src/index.tsRoot workspace config:
{
"name": "pricing-platform",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"build": "tsc -b",
"typecheck": "tsc -b --pretty false",
"clean": "tsc -b --clean"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}An internal package still has a normal package manifest:
{
"name": "@acme/pricing-core",
"version": "1.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"@acme/contracts": "1.2.0"
}
}Workspaces solve package linking. They do not, by themselves, tell TypeScript which package must be built before another package. That is the job of project references.
The habit to build early: import internal packages by their package names, not by climbing across folders. @acme/pricing-core is the same shape consumers will use after publication. ../../pricing-core/src/index is a private source-file reach-through that bypasses the package boundary you just designed.
Project references: the TypeScript build graph
A root solution tsconfig.json gives tsc -b one entry point:
{
"files": [],
"references": [
{ "path": "packages/contracts" },
{ "path": "packages/pricing-core" },
{ "path": "packages/dashboard" }
]
}files: [] is deliberate. This config is the build map, not another project that should compile every source file again.
Each referenced package is a composite project:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/contracts.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}A package that depends on another package names that dependency in references:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/pricing-core.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../contracts" }]
}Now build mode can do the orchestration:
npx tsc -b
npx tsc -b --verbose
npx tsc -b --dry
npx tsc -b --cleanBuild mode reads the reference graph, builds dependencies first, and skips projects that are already up to date. It also treats referenced project boundaries through declaration output. That is the scaling move: changing implementation details inside contracts should not force every downstream package to be rechecked as though all files belonged to one project, unless the public declaration surface changed.
Shared tsconfig bases and path strategy
Use a shared base config for compiler policy:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}Keep package-local facts in package configs: rootDir, outDir, tsBuildInfoFile, include, and references. TypeScript config inheritance does not inherit references, and that is useful. Every package should declare the package projects it depends on; hidden references in a base config would make the graph harder to reason about.
Be conservative with paths in monorepos. paths can make TypeScript resolve an alias, but it does not rewrite emitted JavaScript. In published library code, that is a footgun: the checker might understand @internal/contracts, while Node and consumers have no idea what that specifier means.
Prefer package-name imports plus workspaces:
Good package boundary:
@acme/pricing-core imports @acme/contracts
Risky source reach-through:
@acme/pricing-core imports ../../contracts/src/index.js
Risky alias-only boundary:
@acme/pricing-core imports @internal/contracts
tsconfig paths maps it to ../contracts/src/index.tsUse paths for app-only ergonomics when the bundler/runtime is configured to match. For publishable packages, make the import path be the package contract.
The circular-dependency swamp
Project references need an acyclic graph. If pricing-core references dashboard and dashboard references pricing-core, TypeScript cannot decide which declaration output should exist first.
The fix is architectural, not a compiler flag.
First, draw the package graph as arrows:
contracts -> no internal dependencies
pricing-core -> contracts
dashboard -> contracts, pricing-coreThen keep arrows pointing one way. Lower-level packages should not import higher-level packages. A reusable contracts package is a good place for shared data shapes, event names, and public request/response types. It should not import app UI, database adapters, or feature implementations.
When you find a cycle, use one of these moves:
- Move shared types into a lower package such as
@acme/contracts. - Move shared runtime helpers into a lower package such as
@acme/utils. - Invert the dependency: accept a callback or interface-shaped dependency instead of importing the higher package.
- Split tests, demo code, and adapters out of the core package so production packages do not depend upward.
- Replace broad internal barrels with direct package exports; barrels can hide the arrow that creates the cycle.
The test for the fix is simple: npx tsc -b --dry should be able to list a build order. If it cannot order the graph, your package boundaries are still muddy.
Section 18 is now complete: compile the files, package them honestly, and scale the package graph without lying to the compiler. Next section moves to CI and real-codebase workflows.
Checkpoint
Answer all three to mark this lesson complete