Project Structure and References
Organising a codebase that has outgrown one tsconfig: project references, incremental builds, path mapping, and dependency boundaries the compiler enforces.
Organising a codebase that has outgrown one tsconfig: project references, incremental builds, path mapping, and dependency boundaries the compiler enforces.
The type check takes ninety seconds. Every editor keystroke lags because the language server is holding forty thousand files in memory. And somebody has imported a database client into the shared types package, so every consumer now pulls in a connection pool.
Both problems are structural. A single tsconfig.json over a
large codebase rechecks everything for any change and enforces
no boundaries between its parts. By the end of this lesson you
will split a project into units that check independently and
cannot import each other in the wrong direction.
The foundations course argued this for files. It applies harder at scale:
src/
├── types/ every type in the project
├── utils/ forty unrelated functions
├── services/
└── components/Every feature touches four folders, each shared with everything else — so every change is a wide diff and a likely conflict. Worse, nothing tells you which parts may depend on which.
Group by subject, then order those groups into layers:
src/
├── shared/ types and helpers with no dependencies
├── photos/ one feature
│ ├── index.ts its public surface
│ ├── captions.ts
│ └── storage.ts
├── uploads/
└── app.tsThe rule that makes this work: dependencies point one way.
When two features genuinely need the same thing, it moves down
into shared — a deliberate act, rather than a lateral import
nobody noticed.
A convention that only exists in someone's head is a convention that erodes.
Bad — the rule written in a README.
## Architecture
Features must not import from each other. Shared code lives in
`src/shared`.Good — the rule as a lint error.
{
"rules": {
"import/no-restricted-paths": ["error", {
"zones": [
{
"target": "./src/shared",
"from": "./src/photos",
"message": "shared must not depend on a feature"
},
{
"target": "./src/photos",
"from": "./src/uploads",
"message": "features must not import each other"
}
]
}]
}
}The README version is enforced by whoever notices in review, which means it holds for a few months and then does not — and by the time someone checks, undoing it is a refactor rather than a comment. The lint rule fails the build on the commit that breaks it, when the fix is one import.
dependency-cruiser does the same job with a rule file and can
draw the graph, which is worth running once on any codebase you
have inherited.
For a monorepo, references split one check into several:
Four things this buys.
Incremental checking. Each package emits a .tsbuildinfo,
so changing api does not recheck shared. On a large
codebase this is the difference between ninety seconds and five.
Enforced boundaries. api can import shared because it
references it. Importing web fails, because there is no
reference — a structural rule the compiler enforces, not a lint
rule.
A smaller editor workload. The language server loads the declaration files of referenced projects rather than their source.
Independent builds, which is what makes deploying one package without the others possible.
The cost is real: composite: true requires declaration,
every referenced package must be built before its dependents can
be checked, and the setup is fiddly the first time. It is worth
it for a monorepo and unnecessary for a single application.
declarationMap: true earns its place immediately — without it,
go-to-definition on a referenced package takes you to the
.d.ts file rather than the source.
Deep relative paths are unreadable and break on every move.
The critical caveat: paths is a compiler setting and the
runtime knows nothing about it. tsc does not rewrite the
paths in its output, so unless something else resolves them the
compiled code fails with a module-not-found.
Whatever runs your code needs the same map:
The runtime-native alternative avoids the mismatch entirely:
#-prefixed imports are a Node feature, so they work without
any compiler involvement. In a monorepo, workspace packages are
better still — @myapp/shared is a real package resolved by
your package manager, and needs no path mapping anywhere.
Two details that catch people.
Tests inside src end up in dist unless excluded, so a
published package ships its own tests:
Excluding them from the build means they are not type-checked either, so check them separately:
Both run in CI. Tests are code, and untyped tests hide the same bugs as untyped source.
Config files at the root — vite.config.ts,
eslint.config.ts — are outside src and run in a different
environment. They need their own small config, or they produce
errors about __dirname and Node globals in a browser project.
incremental caches between runs. skipLibCheck skips
dependency declaration files, which is a large win and hides
genuine conflicts between two packages — nearly everyone accepts
that trade.
When a check is slow anyway, measure rather than guess:
The first prints where time went. The second produces a trace you can open in a profiler, which usually identifies one expensive type — the advanced course covers reading it.
The other common cause is an over-broad include pulling in
generated files, fixtures or dist itself. Check what is
actually being compiled before optimising anything:
You can now split a codebase into units that check independently, enforce which parts may depend on which, and keep a large project's feedback loop short. The rule underneath all of it is the layering one — dependencies pointing one way is what makes every other decision here possible.
Next is Dependencies and Their Type Definitions, which covers what happens when the code you depend on is not yours: where types come from when a package has none, how versions drift apart, and what to do about a library whose types are wrong.
Before you move on, run tsc --listFiles | wc -l on a project
you have. If the number is far larger than the files you wrote,
something in include is pulling in more than you intended —
and that is usually the whole explanation for a slow check.
packages/
├── shared/ tsconfig.json
├── api/ tsconfig.json
└── web/ tsconfig.json
tsconfig.json the root, referencing all threea bundler reads tsconfig paths, usually automatically
tsx / vite-node reads them
plain node needs "imports" in package.json insteadSTRUCTURE
group by SUBJECT, then order the groups into layers
shared (no local deps) < features (never each other) < entry
never a types/ utils/ constants/ split for the whole project
a rule in a README erodes; a lint rule fails the build
import/no-restricted-paths, or dependency-cruiser
PROJECT REFERENCES - for a monorepo
"composite": true, "declaration": true, "declarationMap": true
"references": [{ "path": "../shared" }]
tsc --build / --build --watch / --build --force
incremental: change api, do not recheck shared
boundaries the COMPILER enforces, not a lint rule
a lighter editor; independent builds
cost: composite requires declaration, and dependents need
their references built first
declarationMap or go-to-definition lands in a .d.ts
PATHS
"paths": { "@shared/*": ["src/shared/*"] }
the COMPILER does not rewrite them - the runtime must know too
bundlers and tsx read them; plain node does not
node-native: "imports": { "#shared/*": ... } in package.json
best in a monorepo: real workspace packages, no mapping at all
BARRELS
fine at a package boundary, as a public surface
costly internally: loads every re-export, hides cycles
TESTS AND CONFIGS
exclude tests from the build or you ship them
then type-check them in a separate tsconfig.test.json - both in CI
root config files need their own tsconfig (Node globals)
SPEED
incremental + tsBuildInfoFile + skipLibCheck
tsc --noEmit --extendedDiagnostics where the time went
tsc --listFiles | wc -l what is even included// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}// packages/api/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist" },
"references": [{ "path": "../shared" }],
"include": ["src/**/*"]
}tsc --build # builds what changed, in dependency order
tsc --build --watch
tsc --build --force # everything, ignoring the cacheimport { Photo } from "../../../shared/types.js";{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"],
"@photos/*": ["src/photos/*"]
}
}
}import { Photo } from "@shared/types.js";{
"imports": {
"#shared/*": "./dist/shared/*"
}
}{
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "**/__tests__/**"]
}// tsconfig.test.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*", "tests/**/*"]
}{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo",
"skipLibCheck": true
}
}tsc --noEmit --extendedDiagnostics
tsc --noEmit --generateTrace ./tracetsc --listFiles | wc -l