| |

TypeScript 102 🔷 Monorepos with TypeScript

A monorepo is a single repository that contains multiple projects — packages, applications, libraries — that are developed, tested, and released together. The alternative is a polyrepo: one repository per project. Google, Meta, Microsoft, and most large engineering organizations use monorepos. The reasons are coordination and reuse: a change that spans several packages can be made in one commit, tested against the whole system, and released atomically.

TypeScript is not the only thing that defines a monorepo, but it is the layer that determines how well the projects compose. If the TypeScript configuration is wrong, the editor shows errors that do not exist, the build compiles the wrong code, and the type checking becomes the bottleneck. The tools that solve this — package manager workspaces, project references, and build orchestrators — are the subject of this chapter.

Key point: A TypeScript monorepo needs two separate layers of configuration. The package manager workspace (npm, yarn, pnpm, bun) tells the package manager where the packages live and how to link them in node_modules. The TypeScript project references tell the compiler which projects depend on which and enable incremental builds. The two layers must agree: if packages/ui depends on packages/shared in package.json, then packages/ui/tsconfig.json must reference ../shared in its references array .


Why monorepos exist

A monorepo is not a technical requirement. It is an organizational choice that solves specific problems and introduces others.

The atomic change problem. In a polyrepo, a change that affects three packages requires three pull requests, each with its own review, CI run, and deployment. The packages are temporarily out of sync. In a monorepo, the change is one commit. The CI runs against the whole system. The packages are released together. This is the primary reason large organizations adopt monorepos .

The reuse problem. A shared library in a polyrepo must be published to a registry, versioned, and installed by each consumer. The update cycle is measured in days or weeks. In a monorepo, the library is a folder. A change to it is immediately visible to every consumer. The editor resolves the import to the source, and the type checker checks the consumer against the current source .

The consistency problem. A monorepo can enforce consistent TypeScript settings, consistent lint rules, and consistent build configurations across every project. The root tsconfig.base.json is extended by every project. The ESLint config is shared. The CI pipeline runs the same checks everywhere. In a polyrepo, each repository drifts.

The tooling problem. A monorepo requires tooling that understands the project graph. The build orchestrator must know that dashboard depends on analytics, which depends on ui, which depends on shared. It must build them in the right order and skip the ones that have not changed. This is what project references, Nx, and Turborepo provide .

The trade-off. A monorepo is heavier than a polyrepo. The repository is larger. The CI must be smarter about what to build and test. The tooling is more complex. The configuration must be maintained. For a small team with a few packages, the overhead may not be worth it. For an organization with dozens of packages and multiple teams, the coordination benefits dominate.


a. Package Manager Workspaces

The first layer of a TypeScript monorepo is the package manager’s workspace feature. It tells the package manager where the packages are and links them so that imports resolve to the local source rather than a registry version.

npm workspaces use a workspaces field in the root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

When npm install runs in the root, npm installs all dependencies for all workspaces into a single root node_modules. It also creates symlinks in node_modules for each workspace package, so that import { foo } from '@my-org/shared' resolves to packages/shared .

pnpm workspaces use a pnpm-workspace.yaml file at the root:

packages:
  - 'packages/*'
  - 'apps/*'

pnpm’s linking is stricter than npm’s. By default, it does not hoist dependencies, which avoids the phantom dependency problem where a package can import something it did not declare. This strictness is why many TypeScript monorepos prefer pnpm .

yarn workspaces use the same workspaces field as npm:

{
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Yarn v2+ (Berry) uses the workspace: protocol for internal dependencies. In a package’s package.json, the dependency is declared as "@my-org/shared": "workspace:*", which tells Yarn to resolve it to the local workspace rather than a registry version .

The common thread is that the package manager links the packages into node_modules. The TypeScript compiler then resolves the imports through node_modules, which points to the local source. This is what makes the imports work without publishing.


b. TypeScript Project References

The second layer is the TypeScript project references. They tell the compiler which projects depend on which and enable incremental builds. Without them, every project in the monorepo is part of a single TypeScript program, and a change anywhere forces a full recheck .

A project that is referenced by another must have composite: true and declaration: true:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  },
  "include": ["src/**/*"]
}

The composite flag tells TypeScript that the project is part of a larger build. It requires declaration: true so that the project’s type declarations can be consumed by the projects that depend on it . The declarationMap flag enables “Go to Definition” to navigate across project boundaries in supported editors .

Each project that depends on another declares the dependency in its references array:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../shared" },
    { "path": "../ui" }
  ],
  "include": ["src/**/*"]
}

The references array must mirror the dependency graph in package.json. If packages/analytics depends on @my-org/ui in its package.json, then packages/analytics/tsconfig.json must include { "path": "../ui" } in its references. A mismatch means TypeScript either misses a dependency or declares one that does not exist .

The root tsconfig.json is the entry point for tsc --build. It compiles nothing itself and lists every project:

{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/ui" },
    { "path": "./packages/analytics" },
    { "path": "./apps/dashboard" }
  ]
}

The files: [] prevents TypeScript from compiling anything in the root directory. The references array tells tsc --build which projects exist. When the build runs, TypeScript processes the projects in dependency order: shared first, then ui, then analytics and dashboard .

The performance gain is significant. When shared changes, tsc --build rechecks shared, ui, analytics, and dashboard. When analytics changes, only analytics and dashboard are rechecked. The rest of the monorepo is skipped because its .tsbuildinfo file shows no changes .


c. Build Orchestrators and the Alternative Pattern

Project references solve the TypeScript compilation layer, but they do not orchestrate the entire build. The build also includes bundling, testing, linting, and any other tasks the monorepo requires. This is where build orchestrators like Nx and Turborepo come in.

Nx reads the project graph from the package manager workspaces and the TypeScript references. It provides nx sync to automatically keep the references arrays in sync with the package.json dependencies. It also provides nx watch-deps, which watches and rebuilds dependencies when they change. Nx can infer tasks from the tools the project already uses, rather than requiring a separate task configuration .

Turborepo takes a different position. It recommends against TypeScript project references and instead uses an “internal package” pattern. In this pattern, the main and types fields in a package’s package.json point directly to the untranspiled source:

{
  "name": "@my-org/shared",
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

The consuming application transpiles and type-checks the package as if it were part of its own source. There is no intermediate .d.ts file and no TypeScript build step. The editor resolves the import to the source directly .

The trade-off is real. The internal package pattern is simpler to configure and gives the editor all the benefits of project references without the composite and references configuration. But it moves the type checking and transpilation work into the consuming application. As the monorepo grows, the application’s build time grows with the number and size of its dependencies. When that becomes a problem, the packages can be converted back to compiled packages with .d.ts files .

Turborepo’s recommendation against project references is not universally shared. The TypeScript team and the Nx team both recommend project references for large monorepos. The choice depends on the size of the monorepo and the team’s tolerance for configuration .


Complete Example Session

This session builds a TypeScript monorepo with pnpm workspaces, three packages, and project references.

// ============================================
// PART 1: THE ROOT PACKAGE.JSON
// ============================================

// package.json
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "tsc --build",
    "clean": "tsc --build --clean",
    "typecheck": "tsc --build --dry"
  },
  "devDependencies": {
    "typescript": "^5.8.0"
  }
}

// ============================================
// PART 2: THE PNPM WORKSPACE
// ============================================

// pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

// ============================================
// PART 3: THE SHARED PACKAGE
// ============================================

// packages/shared/package.json
{
  "name": "@my-org/shared",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc --build"
  }
}

// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

// packages/shared/src/index.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export function formatUser(user: User): string {
  return `${user.name} <${user.email}>`;
}

// ============================================
// PART 4: THE UI PACKAGE
// ============================================

// packages/ui/package.json
{
  "name": "@my-org/ui",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "dependencies": {
    "@my-org/shared": "workspace:*"
  },
  "scripts": {
    "build": "tsc --build"
  }
}

// packages/ui/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src/**/*"]
}

// packages/ui/src/index.ts
import { User, formatUser } from '@my-org/shared';

export function UserCard(user: User): string {
  return `<div class="user-card">${formatUser(user)}</div>`;
}

// ============================================
// PART 5: THE DASHBOARD APP
// ============================================

// apps/dashboard/package.json
{
  "name": "@my-org/dashboard",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "@my-org/shared": "workspace:*",
    "@my-org/ui": "workspace:*"
  },
  "scripts": {
    "build": "tsc --build"
  }
}

// apps/dashboard/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "references": [
    { "path": "../../packages/shared" },
    { "path": "../../packages/ui" }
  ],
  "include": ["src/**/*"]
}

// apps/dashboard/src/index.ts
import { User } from '@my-org/shared';
import { UserCard } from '@my-org/ui';

const alice: User = { id: '1', name: 'Alice', email: 'alice@example.com' };
console.log(UserCard(alice));

// ============================================
// PART 6: THE ROOT TSCONFIG
// ============================================

// tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/ui" },
    { "path": "./apps/dashboard" }
  ]
}

// ============================================
// PART 7: THE FIRST BUILD
// ============================================

// pnpm install
// pnpm build

// TypeScript processes:
// 1. shared (no dependencies)
// 2. ui (depends on shared)
// 3. dashboard (depends on shared and ui)

// Each project writes its own .tsbuildinfo file.

// ============================================
// PART 8: THE SECOND BUILD
// ============================================

// pnpm build

// Reads the .tsbuildinfo files.
// Detects that nothing has changed.
// Skips all projects.
// Time: < 1 second.

// ============================================
// PART 9: CHANGE A FILE IN shared
// ============================================

// Edit packages/shared/src/index.ts

// pnpm build

// Rechecks shared.
// Rechecks ui (depends on shared).
// Rechecks dashboard (depends on shared and ui).
// Time: a few seconds.

// ============================================
// PART 10: THE EDITOR CONFIGURATION
// ============================================

// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true
}

// This tells VS Code to use the workspace TypeScript version
// and the project references for navigation.

The ten parts cover the root package.json, the pnpm workspace, the shared package, the UI package, the dashboard app, the root tsconfig.json, the first build, the second build, changing a file in shared, and the editor configuration.


Quick Reference

The Two Layers

LayerToolConfiguration
Package managernpm, yarn, pnpm, bunworkspaces field or pnpm-workspace.yaml
TypeScripttsc --buildreferences array, composite: true

The Required Settings

SettingValuePurpose
compositetrueEnable incremental builds
declarationtrueEmit .d.ts files
declarationMaptrueCross-project navigation
rootDir./srcPredictable declaration paths
outDir./distOutput directory

The Workspace Configuration

Package ManagerConfiguration FileInternal Dependency
npmworkspaces in package.json"@my-org/lib": "*"
yarnworkspaces in package.json"@my-org/lib": "workspace:*"
pnpmpnpm-workspace.yaml"@my-org/lib": "workspace:*"
bunworkspaces in package.json"@my-org/lib": "*"

The Build Orchestrators

ToolProject ReferencesAuto-SyncWatch Deps
NxRecommendednx syncnx watch-deps
TurborepoNot recommendedN/AInternal packages
Raw tscRequiredManualManual

The TypeScript Configuration Files

FilePurpose
tsconfig.base.jsonShared compiler options
tsconfig.json (root)Entry point for tsc --build
tsconfig.json (project)Project-specific options and references
.tsbuildinfoIncremental build state

Best Practices

✅ Do This:

// Use composite and declaration in referenced projects
{ "compilerOptions": { "composite": true, "declaration": true } } // ✅
// Mirror package.json dependencies in references
{ "references": [{ "path": "../shared" }] }                    // ✅
# Use tsc --build instead of tsc
npx tsc --build                                                // ✅
// Enable declarationMap for cross-project navigation
{ "compilerOptions": { "declarationMap": true } }              // ✅
// Use files: [] in the root tsconfig
{ "files": [], "references": [] }                              // ✅

❌ Don’t Do This:

// Don't skip declaration in composite projects
{ "composite": true }  // without declaration                  // ❌
// Don't mismatch references and package.json
// If package.json has the dependency, references must have it. // ❌
# Don't run tsc without --build in a project-references setup
npx tsc  // does not use project references                    // ❌
// Don't use a single tsconfig for the entire monorepo
{ "include": ["packages/**/*", "apps/**/*"] }                  // ❌

Common Pitfalls

PitfallWhy It HappensFix
tsc --build does not buildfiles: [] missing in rootAdd files: []
References out of syncManually maintainedUse Nx sync or check manually
Editor errors on fresh clone.d.ts files not builtBuild before opening, or use in-memory redirect
Cross-project navigation failsdeclarationMap not enabledAdd declarationMap: true
Build order wrongReferences mismatch package.jsonMirror the dependency graph
workspace:* not resolvedPackage manager not installedRun pnpm install at root

Real-World Examples

1. Root package.json

{ "private": true, "workspaces": ["packages/*", "apps/*"] }

2. pnpm Workspace

packages:
  - 'packages/*'
  - 'apps/*'

3. Composite tsconfig

{ "compilerOptions": { "composite": true, "declaration": true } }

4. Project References

{ "references": [{ "path": "../shared" }] }

5. Root tsconfig for Build

{ "files": [], "references": [{ "path": "./packages/shared" }] }

6. Build Command

npx tsc --build

7. Internal Dependency

{ "dependencies": { "@my-org/shared": "workspace:*" } }

8. Editor Configuration

{ "typescript.tsdk": "node_modules/typescript/lib" }

9. Nx Sync

npx nx sync

10. Turborepo Internal Package

{ "main": "./src/index.ts", "types": "./src/index.ts" }

Visual

The Two Layers

┌──────────────────────────────────────────────┐
│  PACKAGE MANAGER WORKSPACE                   │
│                                              │
│  package.json: "workspaces": ["packages/*"]  │
│  pnpm-workspace.yaml: packages: ["packages/*"]│
│                                              │
│  Links packages into node_modules.           │
│  @my-org/shared → packages/shared            │
│                                              │
├──────────────────────────────────────────────┤
│  TYPESCRIPT PROJECT REFERENCES               │
│                                              │
│  tsconfig.json: "references": [              │
│    { "path": "./packages/shared" }           │
│  ]                                           │
│                                              │
│  Builds projects in dependency order.        │
│  Skips unchanged projects.                   │
│                                              │
└──────────────────────────────────────────────┘

The Dependency Graph

┌──────────────────────────────────────────────┐
│  MONOREPO DEPENDENCY GRAPH                   │
│                                              │
│  shared                                      │
│    ├─ ui                                     │
│    │    └─ dashboard                         │
│    └─ dashboard                              │
│                                              │
│  package.json:                               │
│    ui depends on @my-org/shared              │
│    dashboard depends on shared and ui        │
│                                              │
│  tsconfig.json references:                   │
│    ui: [{ "path": "../shared" }]             │
│    dashboard: [{ "path": "../../shared" },   │
│                 { "path": "../../ui" }]      │
│                                              │
│  The two must match.                         │
│                                              │
└──────────────────────────────────────────────┘

The Build Order

┌──────────────────────────────────────────────┐
│  tsc --build                                 │
│                                              │
│  1. shared (no dependencies)                 │
│       │                                      │
│       ▼                                      │
│  2. ui (depends on shared)                   │
│       │                                      │
│       ▼                                      │
│  3. dashboard (depends on shared and ui)     │
│                                              │
│  Each project writes .tsbuildinfo.           │
│  The next run skips unchanged projects.      │
│                                              │
└──────────────────────────────────────────────┘

The Turborepo Alternative

┌──────────────────────────────────────────────┐
│  INTERNAL PACKAGE PATTERN                    │
│                                              │
│  packages/shared/package.json:               │
│  {                                           │
│    "main": "./src/index.ts",                 │
│    "types": "./src/index.ts"                 │
│  }                                           │
│                                              │
│  No tsconfig in the package.                 │
│  No build step.                              │
│  The consumer transpiles and type-checks.    │
│                                              │
│  Simpler configuration.                      │
│  Consumer build grows with dependencies.     │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Workspace layerPackage manager links packages
TypeScript layerProject references enable incremental builds
Required for referencescomposite: true, declaration: true
Root tsconfigfiles: [], references array
Build commandtsc --build
Incremental state.tsbuildinfo per project
Cross-project navigationdeclarationMap: true
NxSyncs references, watches dependencies
TurborepoRecommends internal packages
Editortypescript.tsdk for workspace version

Key takeaways:

  • A TypeScript monorepo needs two layers of configuration. The package manager workspace links the packages into node_modules. The TypeScript project references tell the compiler which projects depend on which and enable incremental builds. The two layers must agree .
  • Project references require composite: true and declaration: true. The composite flag enables incremental compilation. The declaration flag emits the .d.ts files that the referenced projects consume. The declarationMap flag enables cross-project navigation .
  • The references array must mirror the package.json dependencies. If packages/ui depends on @my-org/shared in package.json, then packages/ui/tsconfig.json must include { "path": "../shared" }. A mismatch means TypeScript either misses a dependency or declares one that does not exist .
  • tsc --build processes projects in dependency order and skips unchanged projects. The .tsbuildinfo file records the state of each project. When a project and its dependencies are unchanged, the project is skipped. This is what makes large monorepos compile in seconds .
  • The root tsconfig.json is the entry point for the build. It has files: [] and a references array that lists every project. The files: [] prevents TypeScript from compiling anything in the root directory .
  • Nx syncs the references automatically. The nx sync command keeps the references arrays in sync with the package.json dependencies. The nx watch-deps command watches and rebuilds dependencies when they change .
  • Turborepo recommends against project references. It uses the “internal package” pattern instead: the main and types fields point directly to the untranspiled source. The consumer transpiles and type-checks the package. This is simpler to configure but moves the work into the consumer’s build .

Remember: A TypeScript monorepo is two graphs in agreement. The package manager graph says which packages depend on which for installation and linking. The TypeScript graph says which projects depend on which for compilation and type checking. When the graphs match, the editor navigates across packages, the build is incremental, and the type checker checks only what changed. When they disagree, the editor shows errors that do not exist, the build compiles the wrong code, and the type checking becomes the bottleneck. The configuration is the contract between the two graphs. Get it right, and the monorepo scales.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!