Introduction
Managing multiple TypeScript applications that share types and business logic becomes painful quickly—duplicated types lead to sync errors, shared utilities end up copy-pasted across repos, and coordinating releases turns into a coordination nightmare. A well-structured TypeScript monorepo lets your team develop, test, and deploy multiple applications from a single repository while keeping shared code DRY and types in sync. This practical guide walks you through setting up TypeScript monorepos that scale, covering workspaces, project references, shared configs, and build tools.
Why monorepos matter for TypeScript teams
Monorepos aren't mandatory, but they solve real problems. When you have a backend service, a web app, and a mobile client all consuming the same API, sharing the generated types saves hours of manual synchronization. A monorepo also makes refactoring easier—change a shared type in one place, and TypeScript catches breakages across all consumers immediately. And for teams building feature-complete systems, having frontend, backend, and database packages together in one place means fewer repo context switches and clearer dependencies.
Workspaces: organizing packages
Workspaces let you manage multiple npm packages in a single repository. Modern package managers—pnpm and npm—both support this natively, and pnpm is increasingly preferred for its speed and disk efficiency.
Setting up with pnpm workspaces:
Create a pnpm-workspace.yaml at the repository root:
packages:
- 'packages/*'
- 'apps/*'
Your directory structure might look like:
monorepo/
├── pnpm-workspace.yaml
├── packages/
│ ├── shared-types/
│ │ ├── package.json
│ │ └── src/
│ ├── api-client/
│ │ ├── package.json
│ │ └── src/
│ └── utils/
│ ├── package.json
│ └── src/
├── apps/
│ ├── web/
│ │ ├── package.json
│ │ └── src/
│ ├── api/
│ │ ├── package.json
│ │ └── src/
│ └── cli/
│ ├── package.json
│ └── src/
└── package.json (root)In each package's package.json, use the name field to define what others import. For example, packages/shared-types/package.json:
{
"name": "@mycompany/shared-types",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}Now any app can import shared types with import type { User } from '@mycompany/shared-types', and pnpm will resolve it locally without publishing to npm. Running pnpm install at the root creates symlinks between packages automatically.
Real-world scenario: A ride-hailing platform has a backend service and mobile app both needing ride status enums and driver location types. Instead of duplicating these types or maintaining a separate types repository, both consume from @mycompany/shared-types. When status values change, a single update propagates everywhere.
Project references: incremental builds
TypeScript's project references feature compiles packages incrementally, caching intermediate output so only changed packages rebuild. This is critical for monorepo performance.
Create a tsconfig.json in each package with a compilerOptions.composite: true flag:
// packages/shared-types/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"strict": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["dist"]
}Then in the root tsconfig.json, reference all packages:
// tsconfig.json
{
"compilerOptions": {
"composite": false,
"declaration": false,
"skipLibCheck": true
},
"references": [
{ "path": "./packages/shared-types" },
{ "path": "./packages/api-client" },
{ "path": "./packages/utils" },
{ "path": "./apps/web" },
{ "path": "./apps/api" },
{ "path": "./apps/cli" }
]
}Running tsc --build compiles only what's changed. If you modify shared-types, only packages that depend on it recompile. For a monorepo with ten packages, this can reduce build time from 30 seconds to 3 seconds.
Practical tip: Package interdependencies matter. If api-client depends on shared-types, reference them in order—shared-types first, then api-client—so the build graph is correct.
Shared configs: consistent TypeScript rules
Running strict: true everywhere ensures type safety, but different packages might need different settings. Create a base config package that others extend.
Create packages/tsconfig/package.json:
{
"name": "@mycompany/tsconfig",
"version": "1.0.0",
"files": ["base.json", "node.json", "web.json"]
}Then create shared configs:
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
}
}// packages/tsconfig/node.json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2020"],
"outDir": "./dist"
}
}// packages/tsconfig/web.json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"outDir": "./dist"
}
}In each app's tsconfig.json, extend from a shared config:
// apps/api/tsconfig.json
{
"extends": "@mycompany/tsconfig/node.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src"],
"references": [
{ "path": "../../packages/shared-types" }
]
}Now all your packages follow consistent rules—no one accidentally disables strict null checks or uses implicit any in one package while enforcing it in another.
Build tools: Turborepo and Nx
As monorepos grow, running scripts across all packages becomes slow. Tools like Turborepo and Nx cache task outputs and parallelize builds intelligently.
Turborepo approach: Turborepo is lightweight and focuses on task orchestration. After installing it, create turbo.json at the root:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"outputs": ["dist/**"],
"cache": true,
"dependsOn": ["^build"]
},
"test": {
"outputs": ["coverage/**"],
"cache": true,
"dependsOn": ["^build"]
},
"lint": {
"outputs": [],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Now turbo run build builds all packages respecting dependency order and caches results. Running it again is instant if nothing changed. You can also run tasks for specific packages: turbo run build --filter=@mycompany/api-client.
Nx approach: Nx is more opinionated and includes generators for scaffolding packages, but also more heavyweight. It shines when you want automated project graph visualization and dependency enforcement. A basic Nx setup with a similar effect:
// nx.json
{
"affected": {
"defaultBase": "main"
},
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "test", "lint"]
}
}
}
}Use nx affected:build to build only packages affected by your current changes—invaluable for CI/CD pipelines.
Real-world impact: A SaaS team with five apps and eight shared packages went from 5-minute builds to 30 seconds by switching to Turborepo, and that was before local caching. On CI, redeploying after a shared type change used to invalidate all builds; now only affected apps rebuild.
Common mistakes
- Circular dependencies: Package A depends on B depends on A. Monorepos make this error obvious but painful. Use build tools to visualize your dependency graph and enforce acyclic dependencies.
- Forgetting to build before running apps: Shared packages are just source files until built. New developers often get "module not found" errors. Document that
pnpm install && pnpm buildis the first step, or automate it with a setup script. - Inconsistent versioning: Sharing code across packages means version mismatches cause subtle bugs. Use a consistent versioning strategy—either lock all packages to the same version or use semantic versioning with explicit dependency specifications.
- No root-level CI checks: It's tempting to let each package have its own test and lint config. Instead, run linting and testing from the root with consistent rules, then individual packages can add stricter checks if needed.
- Publishing internal packages to npm by accident: Always scope internal packages under an organization namespace like
@company/, and configure npm to never publish private packages.
Key takeaways
- Use workspaces (pnpm or npm) to organize multiple packages as local dependencies, avoiding duplication and enabling single-source-of-truth for shared types.
- Enable TypeScript project references with
composite: trueto cache intermediate builds and scale incrementally as your monorepo grows. - Create a shared tsconfig package that all applications extend from, ensuring consistent compiler rules and reducing configuration drift.
- Adopt Turborepo or Nx to orchestrate builds across packages, parallelize tasks intelligently, and cache results so subsequent runs are near-instant.
- Watch for circular dependencies, enforce acyclic package graphs, and document the first-time setup steps so new developers don't struggle with module resolution.






Comments
Loading comments…