Turborepo + TypeScript Project References: Monorepo Build Optimization and How to Decide When to Use It
When you first introduce a monorepo, you think "I'll just manage everything in one place" — but at some point you find common type definitions copy-pasted everywhere, a single package taking 3 minutes to build, and circular dependencies quietly creeping in. I myself started out thinking it was fine to just split things into package folders and have pnpm install run them all at once, but as the team size and package count grew, I ended up overhauling the build pipeline twice.
This post covers a practical design approach for separating type sharing and build cache strategies layer by layer, by combining TypeScript Project References with Turborepo. Rather than a simple setup guide, the focus is on "why you should design it this way." After reading, you'll be able to judge for yourself whether Project References are necessary for your team's situation, or whether a simpler approach is sufficient. This is written for readers who have basic experience with TypeScript and pnpm workspaces.
These days, the pnpm + Turborepo combination has become the de facto standard for mid-to-large frontend teams. The question has shifted from "should we use a monorepo?" to "how do we structure internal packages and optimize the build pipeline?" I hope this post helps with that decision.
Core Concepts
Two Technologies, Operating on Completely Different Layers
When you first study monorepo build optimization, you run into the confusion of "what's the difference between Project References and Turborepo caching?" Honestly, I initially thought both were just "caching tools," but in practice they operate on completely different layers.
Separation of concerns: TypeScript Project References handles compiler-level incremental builds, while Turborepo handles task-level caching and parallel execution. The real benefit comes when these two layers work together.
A simple analogy: Turborepo says "the build output for this package already exists, so I'll skip the task entirely," while TypeScript Project References says "even if the task runs, I'll only recompile the files that changed." When the two strategies overlap, even if a cache miss occurs and a task runs, the compiler does only the minimum work.
TypeScript Project References — How the Compiler Knows the Dependency Graph
The core of Project References is the references field in tsconfig.json. Declaring it makes the TypeScript compiler aware of inter-package dependencies, and three important things happen.
First, incremental compilation. It records the previous build result in a .tsbuildinfo file and recompiles only the changed packages and their downstream dependents. Unchanged packages continue using their existing .d.ts files.
Second, isolation based on the dependency graph. Direct imports between packages not declared in references are blocked as compile errors. Circular dependencies or implicit boundary violations are caught as type errors.
Third, tsc -b build mode. It automatically builds the entire reference tree in the correct order. If package A depends on package B, it knows to build B first.
// packages/types/tsconfig.json
{
"compilerOptions": {
"composite": true, // Enables incremental builds — allows referencing packages to read .d.ts files
"declaration": true, // Required .d.ts output
"incremental": true,
"outDir": "./dist"
}
}Packages that declare composite: true come with one constraint: all input files must be explicitly listed via include, files, or rootDir rules. In practice, it's quite common to encounter an unexpected error TS6307: File ... is not under 'rootDir' error when this rule is overlooked, so be careful.
The composite option: A package that declares
composite: truemust have anoutDirand build output. Other packages that reference it read the.d.tsdeclaration files directly instead of parsing the source files, for type checking.
Turborepo — The Tool That Knows When to Run and When to Skip Tasks
Turborepo computes a unique cache key based on source files, environment variables, and configuration files, and reuses previous run results. The build task for unchanged packages is skipped entirely without even being executed.
Since Turborepo 2.0, the turbo.json schema has been simplified. The old pipeline key has been removed and tasks are defined directly under tasks.
// turbo.json (v2 and later)
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ^ means "run build for all packages this package depends on first"
"outputs": ["dist/**", ".tsbuildinfo"]
},
"typecheck": {
"dependsOn": ["^build"], // Dependent packages must produce .d.ts files first
"inputs": ["src/**/*.ts", "tsconfig.json"]
}
}
}The
^prefix independsOn:^builddeclares that thebuildtasks of the packages the current package depends on must complete first. In contrast,build(without the prefix) refers to other tasks within the same package.
Internal Package Strategies — Three Options
Turborepo officially proposes three approaches for sharing types. This is also the most important decision point in practice.
| Strategy | How it works | Best suited for |
|---|---|---|
| Just-in-Time (direct source sharing) | The main and types fields in package.json point directly to .ts files. The bundler includes them at consumption time (JIT) |
Fewer than 10 packages, fast iteration |
| Compiled Package | Compiled with tsc, outputting .d.ts + .js. Optimal when combined with Project References |
10+ packages, stable API |
| Publishable Package | External packages published to npm | Open source or sharing with external teams |
The name JIT (Just-in-Time) comes from the fact that the package is not pre-compiled; instead, the consuming app processes the TypeScript source when it bundles. Since the setup is simplest, this approach is actually optimal at small scale.
In 2024, the Turborepo team stated in the post "You might not need TypeScript project references" that the Just-in-Time pattern is simpler and more effective in many cases. The maintenance cost of Project References (manually managing the references array whenever packages are added or removed) can outweigh the actual benefit. "Always use Project References" is not the current industry stance — the choice should be made based on codebase size and team situation.
Practical Application
Example 1: Basic Pattern for Setting Up a Shared Type Package as a Compiled Package
This is the best structure for situations where 10 or more packages share common domain types. The type definition package is separated as an independent compilation unit, and other packages reference .d.ts instead of source files.
monorepo/
├── packages/
│ ├── types/ # Shared type definitions (composite: true)
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/index.ts
│ ├── ui/ # references: [types]
│ │ └── tsconfig.json
│ └── utils/ # references: [types]
│ └── tsconfig.json
├── apps/
│ ├── web/ # references: [types, ui, utils]
│ │ └── tsconfig.json
│ └── admin/ # references: [types, ui]
│ └── tsconfig.json
├── pnpm-workspace.yaml
└── turbo.json// packages/types/package.json
{
"name": "@company/types",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --build"
}
}// packages/types/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"incremental": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"] // Explicitly specifying include is recommended when using composite: true
}// apps/web/tsconfig.json
// In real projects, it's common to inherit a base config via extends
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// Controls how the editor reads referenced packages.
// Without this option, VS Code tries to parse the source files of referenced packages,
// causing the editor to slow down as the number of packages grows.
"disableSourceOfProjectReferenceRedirect": true
},
"references": [
{ "path": "../../packages/types" },
{ "path": "../../packages/ui" },
{ "path": "../../packages/utils" }
]
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", ".tsbuildinfo"],
"inputs": ["src/**", "tsconfig.json", "package.json"]
},
"typecheck": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.tsx", "tsconfig.json"]
},
"lint": {
"inputs": ["src/**", ".eslintrc.*"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}When you run turbo build, here is the actual execution order:
$ turbo build
• Packages in scope: @company/types, @company/ui, @company/utils, web, admin
• Running build in 5 packages
• Remote caching disabled
@company/types:build: cache miss, executing d3f1a2b
@company/types:build: tsc --build
@company/types:build: Done in 0.8s
@company/ui:build: cache miss, executing 8e2c4f1
@company/utils:build: cache miss, executing 9a3b7d2
@company/ui:build: Done in 1.2s
@company/utils:build: Done in 0.9s
web:build: cache hit, replaying output 7f4e8c3
admin:build: cache hit, replaying output 2d9a1b5
Tasks: 5 successful, 5 total
Cached: 2 cached, 5 total
Time: 3.4s >>> FULL TURBOThe dependency package (@company/types) is built first, and ui and utils, which don't depend on each other, are executed in parallel. web and admin get a cache hit and skip execution entirely.
The importance of
inputsconfiguration: If you don't specifyinputs, Turborepo includes every file change within the package in the cache key. Changing a singleREADME.mdinvalidates the cache. Explicitly specifyingsrc/**andtsconfig.jsonis critical for improving real-world cache hit rates.
| Setting | Role |
|---|---|
composite: true |
Declares that this package can be referenced from other packages |
declaration: true |
Outputs .d.ts files; consuming packages read these instead of parsing source |
skipLibCheck: true |
Skips type checking for files inside node_modules, reducing build time |
disableSourceOfProjectReferenceRedirect |
Configures VS Code to read .d.ts directly, saving editor memory |
Note for Next.js apps: Next.js uses its own tsc pipeline, which can conflict with
tsc -b. When a Next.js app is included, it is safer to separateturbo typecheckusingtsc --noEmitand builds usingnext build.
Example 2: The Just-in-Time Pattern — Moving Fast Without Project References
When you still have 5–9 packages, or in an early stage where the shared package API changes frequently, the JIT approach is much simpler. Instead of pre-compiling, the consuming app's bundler (Vite, Webpack, etc.) processes the TypeScript source directly.
Note: The
"main": "./src/index.ts"setting of the JIT approach works well in bundler environments, but is not suitable for server-side packages run directly in Node.js. For server-side packages, the Compiled Package approach is more appropriate.
// packages/types/package.json (JIT approach)
{
"name": "@company/types",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}// packages/types/tsconfig.json — simple, without composite
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler"
},
"include": ["src"]
}Here are the key trade-offs of this approach:
| Item | JIT approach | Compiled approach |
|---|---|---|
| Initial setup complexity | Low | High |
references array management |
Not needed | Manual update with every package addition |
| Editor performance (as packages grow) | Gets progressively slower | Maintained thanks to .d.ts |
| Build time (10+ packages) | Full recompile on shared package change | Only changed parts recompiled |
| Server-side package support | Requires separate handling | Works out of the box |
| Team TypeScript expertise required | Low | High |
The Most Common Mistakes in Practice
This section honestly covers pitfalls that my team and I stepped into ourselves. I hope you can also think about why these mistakes keep recurring.
1. Omitting .tsbuildinfo from outputs
When Turborepo restores a build result from cache, if .tsbuildinfo is not in outputs, TypeScript compiles from scratch without the previous build information. Seeing recompilation happen even after a cache hit is almost always this case. I once spent a long time debugging in CI wondering "why is it taking so long when there's a cache hit?" Including .tsbuildinfo in the outputs of turbo.json is essential.
2. Not making typecheck depend on ^build
Running type checks before the shared package's .d.ts files have been created produces false positives. Adding ["^build"] to typecheck's dependsOn ensures type checking runs after the dependent packages are built. Especially when running tasks in parallel in CI, intermittently occurring type errors are hard to catch when this ordering dependency is missing.
3. Operating at large scale without disableSourceOfProjectReferenceRedirect
Once you exceed 20 packages, VS Code tries to parse all the referenced package sources, making the editor very heavy. If you get a vague feeling that "the editor is slow for some reason," checking this option is a good idea. Setting it in the base tsconfig from the start saves you from later performance headaches.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Reduced build time | Only recompiles changed packages and their dependents. In the Viget case study, build time was reduced by 79% (6.2 min → 1.3 min) |
| Enforced dependency boundaries | Blocks imports between packages not explicitly declared, preventing circular dependencies |
| Editor performance | Type consumption via .d.ts files → reduced VS Code memory usage, improved type check responsiveness |
| CI/CD caching | Team-wide cache sharing via Turborepo Remote Cache. In the Mercari case study, CI task execution time was reduced by 50% |
| Parallel execution | Automatic analysis of the package dependency graph, concurrent execution of independent tasks |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
Manual references management |
Array must be updated whenever packages are added or removed | Consider JIT approach if fewer than 10 packages |
| Unpredictable cache invalidation | Changes to environment variables or config files invalidate the entire cache | Define inputs/outputs granularly |
| Initial setup complexity | Composite packages require outDir and build output |
Standardize with a base tsconfig package |
| Remote Cache cost | Vercel Remote Cache is paid | Operate a self-hosted cache server or evaluate open-source alternatives |
| Overkill at small scale | With fewer than 5 packages, management overhead may outweigh the benefit | Decide the adoption timing based on team size and growth plan |
.tsbuildinfofile: A file where TypeScript's incremental compilation records the previous build result. This file must be included in Turborepo'soutputsfor incremental compilation to work correctly after restoring from cache. Omitting it causes recompilation even after a cache hit.
Closing Thoughts
The key to monorepo build optimization is separating "what caches what" by layer. TypeScript Project References and Turborepo divide that responsibility across different layers.
One more thing to keep in mind: in March 2025, Microsoft announced tsgo (TypeScript 7), a rewrite of the TypeScript compiler in Go, which as of the current beta achieves compilation speeds 10.8× faster than the existing tsc. Support for tsc -b build mode is expected around mid-2026, and once that stabilizes, it may become possible to achieve sufficient build performance without Project References. Rather than locking things into a complex structure now, maintaining the minimal configuration that fits your team's scale is more advantageous in the long run.
No matter how many packages your team is currently operating, the best design is one that is easy to change tomorrow.
Three steps you can start with right now:
-
Start by counting how many packages your current monorepo has. If it's fewer than 10, the JIT approach is a good starting point. Setting the
mainandtypesfields inpackage.jsonto point directly at.tsfiles, and definingbuildandtypechecktasks inturbo.json, is enough to experience significant benefits. -
If you have 10 or more packages, or shared type packages have reached a stable phase, consider converting the less frequently changed packages to
composite: truefirst. Converting frequently changed packages at the same time mixesreferencesarray cleanup with API changes, making debugging difficult — migrating one stable package at a time is much smoother. Using the@typescript/analyze-tracetool to visualize build bottlenecks first makes it much easier to decide which package to convert first. -
Consider connecting Turborepo Remote Cache to your CI. Attaching a Vercel Remote Cache or self-hosted cache server lets the entire team share the cache, and you can experience a significant reduction in CI time for PRs that modify a single package. Fine-tuning the
inputs/outputssettings inturbo.jsonis the key to improving cache hit rates.
Next post: How to correctly configure the
exportsfield for internal packages in a pnpm Workspace environment, and how to solve the CJS/ESM dual package problem in practice usingpublintand conditional exports
References
- You might not need TypeScript project references | Turborepo official blog
- TypeScript in a monorepo | Turborepo official docs
- Internal Packages | Turborepo official docs
- TypeScript Project References official handbook
- Everything You Need to Know About TypeScript Project References | Nx Blog
- Fixing TypeScript Performance Problems: A Case Study | Viget
- Turborepo Remote Cache: Accelerating CI to "Move Fast" | Mercari Engineering
- Progress on TypeScript 7 — December 2025 | Microsoft DevBlogs
- TypeScript Performance Wiki | Microsoft GitHub
- How we configured pnpm and Turborepo for our monorepo | Nhost