TypeScript — Monorepos & Project References
Monorepos amplify TypeScript misconfiguration: one bad paths entry or missing project reference can cascade into stale types and hour-long CI. This guide deep-dives project references with pnpm workspaces.
info
| 1 | repo/ |
| 2 | package.json |
| 3 | pnpm-workspace.yaml |
| 4 | tsconfig.base.json |
| 5 | tsconfig.json # solution-style references only |
| 6 | packages/ |
| 7 | ui/ |
| 8 | package.json |
| 9 | tsconfig.json |
| 10 | src/ |
| 11 | config/ |
| 12 | package.json |
| 13 | tsconfig.json |
| 14 | src/ |
| 15 | apps/ |
| 16 | web/ |
| 17 | package.json |
| 18 | tsconfig.json |
| 19 | src/ |
| 20 |
| 1 | packages: |
| 2 | - "packages/*" |
| 3 | - "apps/*" |
| 4 |
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "strict": true, |
| 4 | "target": "ES2022", |
| 5 | "module": "NodeNext", |
| 6 | "moduleResolution": "NodeNext", |
| 7 | "declaration": true, |
| 8 | "declarationMap": true, |
| 9 | "sourceMap": true, |
| 10 | "composite": true, |
| 11 | "skipLibCheck": true, |
| 12 | "noUncheckedIndexedAccess": true |
| 13 | } |
| 14 | } |
| 15 |
| 1 | { |
| 2 | "files": [], |
| 3 | "references": [ |
| 4 | { "path": "./packages/config" }, |
| 5 | { "path": "./packages/ui" }, |
| 6 | { "path": "./apps/web" } |
| 7 | ] |
| 8 | } |
| 9 |
| 1 | { |
| 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { |
| 4 | "outDir": "dist", |
| 5 | "rootDir": "src", |
| 6 | "composite": true |
| 7 | }, |
| 8 | "include": ["src"], |
| 9 | "references": [{ "path": "../config" }] |
| 10 | } |
| 11 |
Build with tsc -b (build mode). It understands the graph, does incremental emits, and fails if reference order is wrong.
| 1 | pnpm exec tsc -b |
| 2 | pnpm exec tsc -b --clean |
| 3 | pnpm exec tsc -b --force |
| 4 |
| Flag | Meaning |
|---|---|
| composite | Required for referenced projects |
| declaration | Emit .d.ts for consumers |
| declarationMap | Go-to-def into source |
| incremental | Implied by composite |
| 1 | { |
| 2 | "name": "@acme/ui", |
| 3 | "version": "0.0.0", |
| 4 | "private": true, |
| 5 | "type": "module", |
| 6 | "exports": { |
| 7 | ".": { |
| 8 | "types": "./dist/index.d.ts", |
| 9 | "import": "./dist/index.js" |
| 10 | } |
| 11 | }, |
| 12 | "scripts": { |
| 13 | "build": "tsc -b", |
| 14 | "typecheck": "tsc -b --pretty false" |
| 15 | }, |
| 16 | "dependencies": { |
| 17 | "@acme/config": "workspace:*" |
| 18 | } |
| 19 | } |
| 20 |
best practice
Path aliases that cross package boundaries bypass the workspace dependency graph — editors may work while CI/build fails, or vice versa.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "baseUrl": ".", |
| 4 | "paths": { |
| 5 | "@acme/ui": ["../../packages/ui/src"], |
| 6 | "@acme/ui/*": ["../../packages/ui/src/*"] |
| 7 | } |
| 8 | } |
| 9 | } |
| 10 |
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "paths": { |
| 4 | "@/*": ["./src/*"] |
| 5 | } |
| 6 | } |
| 7 | } |
| 8 |
| Anti-pattern | Prefer |
|---|---|
| Alias into another package src | Depend on workspace package + references |
| Duplicate paths in every tsconfig | Share via base only for in-package aliases |
| Skip composite for speed | Pay for graph correctness |
| Point types at src in prod exports | Emit dist .d.ts for publish |
danger
| 1 | { |
| 2 | "typescript.tsserver.experimental.enableProjectDiagnostics": true, |
| 3 | "typescript.enablePromptUseWorkspaceTsdk": true |
| 4 | } |
| 5 |
For app development, tools like tsc -b --watch or package-local Vite/Next typecheck keep references warm. Ensure the workspace TypeScript version is used in the editor.
| 1 | pnpm install --frozen-lockfile |
| 2 | pnpm exec tsc -b --pretty false |
| 3 | pnpm -r run test |
| 4 |
pro tip
Snippet 1 — Solution tsconfig
Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.
| 1 | { "files": [], "references": [{ "path": "./packages/ui" }] } |
Snippet 2 — composite
Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.
| 1 | { "compilerOptions": { "composite": true, "declaration": true } } |
Snippet 3 — workspace dep
Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.
| 1 | { "dependencies": { "@acme/ui": "workspace:*" } } |
Snippet 4 — tsc -b
Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.
| 1 | tsc -b --force |
Snippet 5 — exports types
Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
| 1 | { "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } } |
Snippet 6 — local alias only
Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.
| 1 | { "paths": { "@/*": ["./src/*"] } } |
Snippet 7 — ref order
Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.
| 1 | config → ui → web |
Snippet 8 — clean
Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.
| 1 | tsc -b --clean |
Snippet 9 — declarationMap
Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.
| 1 | "declarationMap": true |
Snippet 10 — skipLibCheck
Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.
| 1 | "skipLibCheck": true |
note
This deep dive expands Monorepo TypeScript with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.
info
Recipe 1: Internal package source mode. Adapt names to your domain; keep the type relationships intact.
| 1 | { |
| 2 | "exports": { |
| 3 | ".": { |
| 4 | "types": "./src/index.ts", |
| 5 | "development": "./src/index.ts", |
| 6 | "import": "./dist/index.js", |
| 7 | "default": "./dist/index.js" |
| 8 | } |
| 9 | } |
| 10 | } |
| 11 |
note
Recipe 2: Boundary lint. Adapt names to your domain; keep the type relationships intact.
| 1 | // Enforce: apps may depend on packages; packages must not depend on apps |
| 2 |
note
Recipe 3: Shared eslint. Adapt names to your domain; keep the type relationships intact.
| 1 | export default tseslint.config({ ignores: ["**/dist/**"] }, /* ... */); |
| 2 |
note
Recipe 4: Change detection. Adapt names to your domain; keep the type relationships intact.
| 1 | pnpm --filter ...@acme/web exec tsc -b |
| 2 |
note
Does this replace runtime checks?
No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.
How do I migrate gradually?
Enable strict flags one at a time, fix a package, then expand. See the migration guide.
What about performance?
Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.
| Question | Short answer |
|---|---|
| Runtime cost of brands/variance annotations? | Zero — compile-time only |
| Need emit? | Often noEmit / bundler handles emit |
| CI gate? | tsc --noEmit + ESLint type-checked |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered Monorepo TypeScript end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
This deep dive expands monorepo with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.
info
Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.
| 1 | export type Flag = "on" | "off"; |
| 2 | export function toggle(f: Flag): Flag { |
| 3 | return f === "on" ? "off" : "on"; |
| 4 | } |
| 5 |
note
Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.
| 1 | export function parse(input: unknown): string { |
| 2 | if (typeof input !== "string") throw new Error("string"); |
| 3 | return input; |
| 4 | } |
| 5 |
note
Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.
| 1 | function assertNever(x: never): never { throw new Error(String(x)); } |
| 2 | type K = "a" | "b"; |
| 3 | export function f(k: K) { |
| 4 | switch (k) { |
| 5 | case "a": return 1; |
| 6 | case "b": return 2; |
| 7 | default: return assertNever(k); |
| 8 | } |
| 9 | } |
| 10 |
note
Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.
| 1 | export function sum(nums: readonly number[]): number { |
| 2 | return nums.reduce((a, b) => a + b, 0); |
| 3 | } |
| 4 |
note
Does this replace runtime checks?
No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.
How do I migrate gradually?
Enable strict flags one at a time, fix a package, then expand. See the migration guide.
What about performance?
Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.
| Question | Short answer |
|---|---|
| Runtime cost of brands/variance annotations? | Zero — compile-time only |
| Need emit? | Often noEmit / bundler handles emit |
| CI gate? | tsc --noEmit + ESLint type-checked |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered monorepo end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
End-to-end worked example for monorepo. Copy into a scratch project with strict: true.
| 1 | export type Flag = "on" | "off"; |
| 2 | export function toggle(f: Flag): Flag { |
| 3 | return f === "on" ? "off" : "on"; |
| 4 | } |
| 5 |
Step-by-step
1. Paste the snippet. 2. Introduce a deliberate type error. 3. Fix it without assertions. 4. Add a second consumer that should fail if variance/brands/refs are wrong.
5. Run npx tsc --noEmit. 6. Commit the learning as a unit test or type test with @ts-expect-error.
info
| Step | Pass criteria |
|---|---|
| Compiles under strict | No errors |
| Intentional misuse fails | @ts-expect-error lights up |
| Runtime path tested | Vitest or node assert |
| Docs updated | README / PR note |
| 1 | import type { Expect, Equal } from "./type-tests"; |
| 2 | |
| 3 | // Local helpers if you lack a type-test lib: |
| 4 | type Equal<A, B> = |
| 5 | (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; |
| 6 | type Expect<T extends true> = T; |
| 7 | |
| 8 | type _smoke = Expect<Equal<true, true>>; |
| 9 | // Topic: monorepo |
| 10 |
best practice
Shipping notes teams hit in production when adopting these patterns: version skew between editor TypeScript and CI, incomplete include globs, and silent any from third-party DefinitelyTyped stubs.
| 1 | npx tsc --noEmit |
| 2 | npx eslint . |
| 3 | git diff --exit-code # ensure no accidental emit |
| 4 |
warning
Document peer dependency TypeScript ranges for libraries. For apps, pin the TypeScript version in the workspace and enable the workspace SDK in VS Code/Cursor.
| 1 | { |
| 2 | "devDependencies": { |
| 3 | "typescript": "~5.8.0" |
| 4 | }, |
| 5 | "packageManager": "pnpm@9" |
| 6 | } |
| 7 |
Rollback plan
If a strict flag floods errors, revert the flag, keep fixed files, and re-enable on a smaller glob via a nested tsconfig. Progress should be monotonic even when flags temporarily retreat.
pro tip
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.