Composite builds, tsc -b, and clean package boundaries
Running TypeScript in a monorepo without project references means waiting for a full type check of the entire codebase on every change, with no real type safety enforced between packages. Project references, composite builds, and a shared base configuration create an explicit dependency graph, fast incremental builds, and reliable boundaries between packages, even as the number of packages grows.
Table of Contents
- 1. Why Monorepos Need Their Own TypeScript Strategy
- 2. Project References in Detail: composite, references, prepend
- 3. Composite Builds and Build Order with tsc -b
- 4. A Shared tsconfig.base.json for All Packages
- 5. Path Aliases vs. Project References
- 6. Declaration Files (.d.ts) and Output Structure
- 7. Integrating pnpm Workspaces, Turborepo, and Nx
- 8. Incremental Build Caching and Performance
- 9. Common Monorepo Pitfalls Compared
- 10. Summary
- 11. FAQ
1. Why Monorepos Need Their Own TypeScript Strategy
In a monorepo with multiple TypeScript packages, the codebase grows faster than the boundaries between packages stay clean. Without project references, the TypeScript compiler by default treats the entire repository as a single compilation context: any change in a deeply nested file can potentially trigger a type check of the whole project, even when only a single package is actually affected. With ten or twenty packages, this naive approach becomes noticeably slow, both in the editor via the TS language server and in the CI pipeline.
Project references solve this by turning each package into an independent TypeScript project unit, with its own tsconfig.json, its own build output, and an explicitly declared dependency graph to other packages. This lets the compiler know the order in which packages must be built, and it can reuse type information from already compiled declaration files instead of re-parsing source code from other packages. The result is a setup that scales with the number of packages instead of getting linearly slower.
2. Project References in Detail: composite, references, prepend
A package becomes a "composite project" once compilerOptions.composite is set to true in its tsconfig.json. composite automatically enforces several conditions: declaration must be enabled so other packages can consume type information without access to the source code, rootDir and include must be set so the compiler knows exactly which files belong to the project, and every referenced file must fall within that boundary. A package that sets composite: true can no longer freely reach into files outside its rootDir.
The actual link is made through the references array: { "path": "../core" } tells the compiler that this package depends on packages/core. When building the dependent package, tsc first checks whether core is up to date and builds it first if needed. The less commonly used prepend option inlines the compiled output of referenced projects directly into the output file, which is only relevant for outFile-based bundles and is practically never needed in modern ESM setups with separate output directories.
// File: packages/core/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src"],
"references": []
}
// File: packages/api/tsconfig.json
// api depends on core, so it lists core in "references"
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src"],
"references": [
{ "path": "../core" }
]
}
3. Composite Builds and Build Order with tsc -b
tsc -b, the compiler's build mode, differs fundamentally from a regular tsc invocation. Instead of compiling a single tsconfig.json, tsc -b reads the entire references graph, sorts the packages topologically, and builds them in exactly the order their dependencies require. A package is only rebuilt if its source files or one of its dependencies has changed since the last build. This state is tracked per package in a .tsbuildinfo file, which stores timestamps, file hashes, and the compiler options used.
In practice, a single command like npx tsc -b packages/api is enough to build the entire dependency tree correctly: TypeScript automatically builds packages/core first if api depends on it. The --force flag forces a full rebuild regardless of the cache state, --clean removes all build artifacts and .tsbuildinfo files, and --watch monitors all referenced projects simultaneously and rebuilds only the affected packages on changes, in the correct order.
# Build all packages in correct topological order,
# starting from the given project as the entry point
npx tsc -b packages/api
# Force a full rebuild, ignoring the .tsbuildinfo state
npx tsc -b packages/api --force
# Remove all build outputs and .tsbuildinfo files
npx tsc -b packages/api --clean
# Watch mode: rebuild only what changed, respecting the reference graph
npx tsc -b packages/api --watch
# Verbose output to see which projects were skipped as up to date
npx tsc -b packages/api --verbose
4. A Shared tsconfig.base.json for All Packages
Without a shared base configuration, the same block of compilerOptions repeats in every single package, which means any adjustment to strict rules or the target version has to be kept in sync across ten different files. A tsconfig.base.json at the repo root centrally defines all shared options such as target, strict, esModuleInterop, skipLibCheck, and moduleResolution. Every package extends this base with extends: "../../tsconfig.base.json" and only overrides package-specific values like outDir, rootDir, and tsBuildInfoFile.
One important detail: composite and declaration do not necessarily need to live in the base if not every package should be a composite project, for example a pure test or tooling package with no consumers of its own. A second, smaller intermediate layer such as a package-specific tsconfig.build.json can enable additional build-only options like declarationMap or sourceMap, while a tsconfig.json with test-relevant includes is kept separate for the editor and the test runner. This separation prevents test files from accidentally ending up in the production build.
// File: tsconfig.base.json (repo root)
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"composite": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
}
}
5. Path Aliases vs. Project References
Path aliases via the paths option in compilerOptions solve a different problem than project references: they only change how the compiler and the editor resolve an import path such as @mironsoft/core within a single TypeScript program. paths has no influence on the emitted JavaScript output, no effect on build order, and enforces no boundary between packages. A package can, in theory, reach through an alias into internal, non-exported files of another package, because paths is simply a file-path mapping, not a module boundary.
Project references solve the structural problem: composite project plus references forces a package to be consumed only through its actually exported declaration files, and the build fails if a package tries to import another one that isn't explicitly listed in references. In practice, teams combine both: paths for short, convenient import paths in the editor, project references for the actual build and type graph. Bundlers like esbuild or Vite resolve paths themselves at build time, but need a plugin like vite-tsconfig-paths to do so, since they don't natively respect tsconfig.json.
6. Declaration Files (.d.ts) and Output Structure
For a package in a monorepo to be consumable by another without re-parsing its source code, declaration: true must be set. The compiler then emits a .d.ts file alongside the compiled .js file, mirroring the same directory structure in outDir. Consuming packages read exclusively these declaration files, which significantly speeds up type checking because TypeScript no longer needs a full re-analysis of the foreign source code, only the already-resolved signatures.
declarationMap: true additionally generates .d.ts.map files, letting "Go to Definition" in the editor jump directly to the original .ts source file instead of the generated declaration, which noticeably improves the developer experience in a monorepo. In every package's package.json, the types field (or exports with a types condition) should point exactly to the emitted .d.ts file in the dist directory, not to the source file. A common mistake is pointing main and types in package.json at src instead of dist, causing consumers to import unbuilt source files and losing the entire benefit of the composite build.
7. Integrating pnpm Workspaces, Turborepo, and Nx
pnpm workspaces form the foundation for package management in the monorepo: through the workspace: protocol in package.json, packages reference one another with exact, locally symlink-based resolution, without pulling versions from the npm registry. TypeScript project references and pnpm workspaces solve different, complementary problems: pnpm makes sure node_modules is linked correctly, while project references make sure the compiler knows the build order and the type boundaries. Both configurations should be kept in sync; every dependency in package.json should also exist as a references entry.
Turborepo and Nx build on top of this structure by defining their own task graphs with dependsOn: ["^build"], meaning a package's build task only starts once all its dependencies have finished building. In practice, this mirrors exactly the order tsc -b already computes from the references graph, but with additional remote caching through a cloud service or a self-hosted cache backend. Nx can even infer the project graph automatically from import statements and reconcile it with the tsconfig structure, catching inconsistent references entries early.
// File: turbo.json (repo root)
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
8. Incremental Build Caching and Performance
The key building block for incremental builds is the .tsbuildinfo file that tsc -b writes per package into outDir. It contains hashes of all source files, the compiler options used, and the signatures of exported types. On every subsequent build, TypeScript compares these hashes: if neither a source file nor a referenced dependency has changed, the compiler skips the package entirely without even opening a file. incremental: true must be set alongside composite for this, though composite automatically implies incremental.
Turborepo and Nx extend this local caching with remote caching: a hash of source files, lockfile version, and compiler options is used as the cache key, and on a hit, the entire dist folder including .tsbuildinfo is downloaded from a shared cache instead of being rebuilt. This is especially effective in CI pipelines when multiple branches share the same unchanged package state. Important: if the .tsbuildinfo file is accidentally checked into the git repository instead of being added to .gitignore, this leads to inconsistent build states across developer machines, because absolute paths and timestamps are machine-specific.
9. Common Monorepo Pitfalls Compared
The most common mistake in growing monorepos is a circular dependency between packages: package A imports a type from package B, while B simultaneously imports something from A. TypeScript refuses to build in this case with the error "Project references may not form a circular graph" (TS6202). The fix is almost always the same: extract the commonly needed type or logic into a third, dependency-free package that both sides reference, instead of referencing each other.
A second, more subtle problem is inconsistent TypeScript versions between packages. If pnpm or npm hoists or installs different typescript versions from devDependencies in different packages, identical .d.ts files can be interpreted slightly differently depending on the compiler version used, leading to type errors that don't reproduce consistently. The fix: declare typescript as a single version in the root package.json, enforce it in pnpm with the pnpm.overrides field or tools like syncpack, and check in CI with tsc --version at a central point that all packages are actually using the same compiler version.
// File: packages/core/src/index.ts
// WRONG: core imports from api, but api already references core -> cycle
import type { ApiResponse } from '@mironsoft/api';
// error TS6202: Project references may not form a circular graph.
export interface CoreEntity {
id: string;
}
// RIGHT: extract the shared type into a third, dependency-free package
// File: packages/shared-types/src/index.ts
export interface CoreEntity {
id: string;
}
export interface ApiResponse<T> {
data: T;
meta: { requestId: string };
}
// packages/core and packages/api both reference packages/shared-types,
// but never reference each other -> no cycle
| Aspect | Without Project References | With Project References (composite) | Effect |
|---|---|---|---|
| Type check on change | Full repo check | Only affected packages via tsc -b | Much faster feedback loops |
| Package boundaries | Convention only (paths) | Enforced by the compiler | No accidental internal imports |
| Circular dependencies | Only visible at runtime | Build fails immediately with TS6202 | Early, clear error message |
| CI caching | No intermediate state, rebuilds everything | .tsbuildinfo + remote cache | Shorter CI run times |
| Editor performance | One TS server checks everything | References isolate the language server scope | Responsive editor with many packages |
In practice these pitfalls are often connected: a team that avoids circular references through clean package boundaries usually also has fewer problems with inconsistent TypeScript versions, because both disciplines benefit from the same underlying mindset, namely modeling dependencies explicitly instead of implicitly. Teams that consistently apply the recommendations from the table above gain both faster builds and more reliable type checking across the entire monorepo.
Mironsoft
TypeScript tooling, frontend architecture, and type-safe headless integrations
Need a TypeScript monorepo with a clean build architecture?
We structure existing or new monorepos with project references, set up composite builds with tsc -b, and integrate Turborepo or Nx for fast, cached CI pipelines, including a shared tsconfig.base.json and clean package boundaries.
Monorepo Audit
Analyze the existing tsconfig structure, find circular references and inconsistent TS versions
Project References Setup
Set up composite builds, tsc -b build order, and a shared base configuration
CI/CD Integration
Configure Turborepo or Nx with remote caching for fast, reproducible pipelines
10. Summary
TypeScript project references solve the core problem of growing monorepos: an explicitly declared, compiler-enforced dependency structure between packages instead of loose conventions. composite: true plus a references array turn every package into an independent build unit, tsc -b builds these units in topological order and automatically skips unchanged packages via the .tsbuildinfo file. A shared tsconfig.base.json keeps common compiler options centralized, while each package only sets outDir, rootDir, and tsBuildInfoFile individually.
Path aliases remain useful for short import paths in the editor, but they don't replace the structural safety net that project references provide. The biggest traps, circular references and inconsistent TypeScript versions between packages, can be avoided with clear rules: extract shared types into dependency-free packages and enforce a single TypeScript version across the entire repository. Combining these building blocks results in a monorepo that scales with the number of packages instead of getting slower with every build.
TypeScript in Monorepos: Project References - The Essentials at a Glance
Project References
composite: true plus a references array enforce an explicit, compiler-checked dependency graph between packages.
tsc -b Build Order
Builds packages in topological order, automatically skips unchanged packages via .tsbuildinfo.
Shared Base Configuration
Maintain tsconfig.base.json centrally, override only outDir, rootDir, and tsBuildInfoFile per package.
Common Pitfalls
Catch circular references early (TS6202), keep the TypeScript version in sync across all packages.