|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/monorepo
$cat docs/typescript-—-monorepos-&-project-references.md
updated Today·22 min read·published

TypeScript — Monorepos & Project References

TypeScriptMonorepoToolingAdvanced🎯Free Tools
Introduction

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

Prefer project references + workspace packages over loose path mapping across package boundaries.
Recommended Layout
tree.txt
TEXT
1repo/
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
pnpm-workspace.yaml
YAML
1packages:
2 - "packages/*"
3 - "apps/*"
4
tsconfig.base.json
JSON
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
Project References Deep Dive
tsconfig.json
JSON
1{
2 "files": [],
3 "references": [
4 { "path": "./packages/config" },
5 { "path": "./packages/ui" },
6 { "path": "./apps/web" }
7 ]
8}
9
packages-ui-tsconfig.json
JSON
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.

build.sh
Bash
1pnpm exec tsc -b
2pnpm exec tsc -b --clean
3pnpm exec tsc -b --force
4
FlagMeaning
compositeRequired for referenced projects
declarationEmit .d.ts for consumers
declarationMapGo-to-def into source
incrementalImplied by composite
pnpm Workspaces + TypeScript
packages-ui-package.json
JSON
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

Use workspace:* protocol. Publishable packages should export types condition pointing at declarations.
Path Mapping Pitfalls

Path aliases that cross package boundaries bypass the workspace dependency graph — editors may work while CI/build fails, or vice versa.

bad-paths.json
JSON
1{
2 "compilerOptions": {
3 "baseUrl": ".",
4 "paths": {
5 "@acme/ui": ["../../packages/ui/src"],
6 "@acme/ui/*": ["../../packages/ui/src/*"]
7 }
8 }
9}
10
good-approach.json
JSON
1{
2 "compilerOptions": {
3 "paths": {
4 "@/*": ["./src/*"]
5 }
6 }
7}
8
Anti-patternPrefer
Alias into another package srcDepend on workspace package + references
Duplicate paths in every tsconfigShare via base only for in-package aliases
Skip composite for speedPay for graph correctness
Point types at src in prod exportsEmit dist .d.ts for publish

danger

Bundlers may resolve aliases TypeScript does not (or the reverse). Keep a single source of truth — usually package exports.
Editor & Watch Mode
vscode-settings.json
JSON
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.

CI Typecheck Graph
ci.sh
Bash
1pnpm install --frozen-lockfile
2pnpm exec tsc -b --pretty false
3pnpm -r run test
4
🔥

pro tip

Cache *.tsbuildinfo carefully — invalidate on tsconfig changes.
Practice Snippets

Snippet 1 — Solution tsconfig

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

monorepo-1.ts
TypeScript
1{ "files": [], "references": [{ "path": "./packages/ui" }] }

Snippet 2 — composite

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

monorepo-2.ts
TypeScript
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.

monorepo-3.ts
TypeScript
1{ "dependencies": { "@acme/ui": "workspace:*" } }

Snippet 4 — tsc -b

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

monorepo-4.ts
TypeScript
1tsc -b --force

Snippet 5 — exports types

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

monorepo-5.ts
TypeScript
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.

monorepo-6.ts
TypeScript
1{ "paths": { "@/*": ["./src/*"] } }

Snippet 7 — ref order

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

monorepo-7.ts
TypeScript
1config → ui → web

Snippet 8 — clean

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

monorepo-8.ts
TypeScript
1tsc -b --clean

Snippet 9 — declarationMap

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

monorepo-9.ts
TypeScript
1"declarationMap": true

Snippet 10 — skipLibCheck

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

monorepo-10.ts
TypeScript
1"skipLibCheck": true
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Deep Dive — Monorepo TypeScript

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

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Internal package source mode

Recipe 1: Internal package source mode. Adapt names to your domain; keep the type relationships intact.

dev-exports.json
TypeScript
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

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Boundary lint

Recipe 2: Boundary lint. Adapt names to your domain; keep the type relationships intact.

deps.ts
TypeScript
1// Enforce: apps may depend on packages; packages must not depend on apps
2
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Shared eslint

Recipe 3: Shared eslint. Adapt names to your domain; keep the type relationships intact.

eslint.config.mjs
TypeScript
1export default tseslint.config({ ignores: ["**/dist/**"] }, /* ... */);
2
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Change detection

Recipe 4: Change detection. Adapt names to your domain; keep the type relationships intact.

ci-filter.sh
TypeScript
1pnpm --filter ...@acme/web exec tsc -b
2
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

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.

QuestionShort 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
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered Monorepo TypeScript end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Deep Dive — monorepo

This deep dive expands monorepo with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Core pattern

Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.

monorepo-a.ts
TypeScript
1export type Flag = "on" | "off";
2export function toggle(f: Flag): Flag {
3 return f === "on" ? "off" : "on";
4}
5
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Boundary parse

Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.

monorepo-b.ts
TypeScript
1export function parse(input: unknown): string {
2 if (typeof input !== "string") throw new Error("string");
3 return input;
4}
5
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Exhaustive

Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.

monorepo-c.ts
TypeScript
1function assertNever(x: never): never { throw new Error(String(x)); }
2type K = "a" | "b";
3export 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

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Readonly params

Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.

monorepo-d.ts
TypeScript
1export function sum(nums: readonly number[]): number {
2 return nums.reduce((a, b) => a + b, 0);
3}
4
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

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.

QuestionShort 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
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered monorepo end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Worked Example

End-to-end worked example for monorepo. Copy into a scratch project with strict: true.

monorepo-worked.ts
TypeScript
1export type Flag = "on" | "off";
2export 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

If the example feels large, extract types into a dedicated file and keep runtime logic thin.
StepPass criteria
Compiles under strictNo errors
Intentional misuse fails@ts-expect-error lights up
Runtime path testedVitest or node assert
Docs updatedREADME / PR note
monorepo-type-test.ts
TypeScript
1import type { Expect, Equal } from "./type-tests";
2
3// Local helpers if you lack a type-test lib:
4type Equal<A, B> =
5 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
6type Expect<T extends true> = T;
7
8type _smoke = Expect<Equal<true, true>>;
9// Topic: monorepo
10

best practice

Keep type tests in CI alongside unit tests — they are documentation that cannot rot silently.
Production Notes

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.

verify.sh
Bash
1npx tsc --noEmit
2npx eslint .
3git diff --exit-code # ensure no accidental emit
4

warning

Never commit skipLibCheck: false flips without measuring CI time — but do not use skipLibCheck to hide broken local types.

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.

package-engines.json
JSON
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

Track error counts in CI comments so migrations stay visible to the whole team.
$Blueprint — Engineering Documentation·Section ID: TS-MONOREPO·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.