Splitting a Monolithic TypeScript Codebase with Project References
TypeScript project references split a monolithic codebase into independently compiled units: faster incremental builds, compiler-enforced module boundaries, and a worked example of extracting a shared package.
17 Nov 2025, 08:44 UTC

Your TypeScript app started as one src/ folder and one tsconfig.json. Two years later, a full type-check takes minutes, every small edit recompiles the world, and the "shared utilities" folder has become a junk drawer that everything imports from everything else. The takeaway: TypeScript project references let you split that monolith into independently compiled units, so incremental builds only redo the projects that actually changed — and the compiler enforces the boundaries you draw.
What project references actually do
Introduced in TypeScript 3.0, project references connect multiple tsconfig.json files into a build graph. Each referenced project must set "composite": true, which forces two things: the project emits a declaration file (.d.ts) for every source file, and it records build state in a .tsbuildinfo file. Those declarations are the key: when project A imports from project B, the compiler type-checks A against B's declarations, not B's source. If B hasn't changed, its declarations are reused and B is skipped entirely.
You orchestrate the whole graph with tsc --build (or tsc -b). It walks the references, builds dependencies in the right order, and only rebuilds projects whose inputs changed. This is the same incremental machinery a single project uses, but now scoped per-package instead of per-repository.
Setting up the split
A typical layout after splitting looks like this:
repo/
tsconfig.json // solution file: only references, no files
packages/
shared/
tsconfig.json // composite: true
src/
app/
tsconfig.json // references ../shared
src/The root solution file contains no source of its own:
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/app" }
]
}Each leaf project opts into the graph. The shared package:
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src"]
}And the app declares its dependency:
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [{ "path": "../shared" }]
}Run tsc --build from the repo root (no special permissions needed; it only writes the outDir folders and .tsbuildinfo files). The meaningful placeholders here are outDir and rootDir — they must be consistent within each project, or you get stale or misplaced declaration output.
Worked example: extracting the shared utilities
Say src/utils/date.ts and src/utils/money.ts are imported by your web app, your CLI, and your test harness. Move them into packages/shared/src/, give that folder its own composite config, and add a reference from each consumer. Imports change from deep relative paths to a package-style path:
// before
import { formatMoney } from "../../utils/money";
// after (with a paths mapping or package name)
import { formatMoney } from "@myrepo/shared";To make @myrepo/shared resolve, add a path mapping in each consumer's config:
"paths": {
"@myrepo/shared": ["../shared/src"]
}When project references are in place, TypeScript redirects that source path to the built declarations automatically during build mode, so consumers type-check against dist/*.d.ts once shared is compiled. The practical win: edit a file in app, run tsc -b, and shared is skipped entirely — its .tsbuildinfo shows no recompilation. In a large repo this is the difference between rebuilding one package and rebuilding hundreds of files.
Verify the behavior rather than trusting it: run tsc --build --verbose before and after touching a single file in one project. The verbose log names each project it considers up-to-date or stale. You can also inspect the .tsbuildinfo files — their timestamps and contents tell you exactly which projects were re-emitted. This is a state-changing operation only in the sense that it writes build output; deleting dist/ and *.tsbuildinfo and rebuilding cleanly is the full reset if anything looks stale.
Boundaries become compiler-enforced
The less obvious benefit is architectural. Once app references shared but not vice versa, any import from shared back into app creates a cycle — and TypeScript rejects circular project references outright with an error at build time. Dependencies that were previously conventions ("please don't import app code from utils") become hard constraints. Combined with lint rules that forbid deep relative imports across package roots, the module graph you drew on a whiteboard starts matching the one the compiler sees.
Trade-offs worth knowing up front
Project references are not free. The configuration surface grows: every package needs its own config with consistent outDir/rootDir, and mismatches produce confusing stale-declaration bugs. Circular references are a hard error, so splitting a tangled codebase may require untangling imports first — which is real work, though arguably work you needed anyway. Editor behavior can also lag: some IDE setups resolve cross-project imports to source while the build uses declarations, and the two can disagree if your path mappings are sloppy. Keep mappings pointed at source and let build mode redirect; that keeps "go to definition" pleasant and builds correct.
Also note this is a build-time and type-check-time tool. It does not bundle, tree-shake, or change what runs in production — your bundler or runtime still needs its own resolution story for the emitted output.
Where to start
Don't split everything at once. Pick one folder with no inbound dependencies — shared utilities are the classic candidate — extract it into a composite project, add the reference, and measure: tsc --build --verbose timings before and after, plus a clean tsc --noEmit pass to confirm cross-project imports still resolve. If the incremental rebuild skips the untouched project, you have the pattern working, and every subsequent extraction follows the same recipe.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.