Use Monorepos with TypeScript

Learn how to set up and manage TypeScript monorepos using project references, composite tsconfig files, and workspace packages for fast, incremental builds.

8 min read

A TypeScript monorepo puts multiple packages in one repository. The promise is shared tooling, atomic changes across packages, and one place to manage everything. The risk is slow builds: changing one shared utility triggers a full recompile of every package that imports it.

TypeScript project references solve exactly this problem. They let the compiler treat each package as an independent project with its own configuration, its own output, and its own incremental build state.

A change in a utility library recompiles only that library and the packages that directly depend on it.

How Project References Work

A project reference is a setting in tsconfig that declares one project depends on another. When you reference a project, TypeScript stops reading its source files directly. Instead, it reads the compiled declaration files that the referenced project produces.

This is the key insight: dependents consume types from declaration output, not from source. The declaration files are smaller, faster to parse, and represent the public API of the dependency.

Monorepo build graph with project references

Each box is a separate TypeScript project with its own tsconfig. The arrows show references, and building the e2e-tests project triggers builds of app-web and app-api, which in turn trigger a build of shared.

But if only shared changes, only shared and its direct dependents rebuild.

Set Up a Minimal Monorepo

Start with three packages: a shared types library and two applications that use it. Each package gets its own directory and tsconfig. The shared package needs composite mode and declaration output enabled so dependents can consume its types.

The shared package configuration tells TypeScript to produce declaration files and enable composite mode, which is required for referenced projects:

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

The composite flag does three things. It forces rootDir to default to the tsconfig directory, and it requires all source files to be listed explicitly via include.

It also enables declaration output automatically.

The declarationMap flag adds source maps for declarations so editors can navigate to the original source.

An application that depends on shared declares the reference in its own tsconfig and sets a different outDir so outputs do not collide:

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

The references array points to the shared package directory. TypeScript resolves the tsconfig inside that directory and uses the declaration files from its dist folder. The application never reads the shared source directly.

The Root Solution tsconfig

A monorepo with many packages benefits from a single entry point that references everything. Create a tsconfig at the repository root that has no source files of its own, only references:

jsonjson
{
  "files": [],
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/app-web" },
    { "path": "packages/app-api" }
  ]
}

The empty files array prevents the root config from trying to compile anything. Running tsc --build at the root builds all three packages in dependency order. The shared package builds first, then both apps build in parallel because neither depends on the other.

This approach scales well. Each package only needs to list its direct dependencies in its references. The build orchestrator resolves the full graph and builds in the correct sequence.

Package Imports in a Monorepo

When one package imports from another, TypeScript follows the project reference. If the import resolves to a package that has a reference entry, the compiler reads the declaration output instead of the source.

Here is how a typical import looks. The shared package exports a User type from its source file, and the declaration output contains the same type in a smaller .d.ts file:

typescripttypescript
// packages/shared/src/user.ts
export type User = { id: string; email: string };

The application package imports from shared. TypeScript resolves this through the reference and reads the compiled declaration file instead of the original source:

typescripttypescript
// packages/app-api/src/handler.ts
import type { User } from "@acme/shared";
 
function greet(user: User): string {
  return `Hello, ${user.email}`;
}

For this import path to work, the shared package needs a name field in its package.json, and the consuming tsconfig needs a path alias or the packages need to be set up as npm workspaces.

The important part is that TypeScript reads the declaration, not the source, which enforces that only the public API of shared is visible to consumers.

Combine with npm Workspaces

Project references handle compilation, and npm workspaces handle package linking. Together they form a complete monorepo setup without additional tooling.

The root package.json declares the workspace directories:

jsonjson
{
  "workspaces": ["packages/*"]
}

Each package has its own package.json with a name that matches the import path. The key settings are the name for imports and the main/types fields pointing to the compiled output:

jsonjson
{
  "name": "@acme/shared",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

Now other packages can import from @acme/shared, and TypeScript resolves the types through the project reference while Node resolves the JavaScript through the workspace symlink. The build order is tsc --build first, then the runtime can use the compiled output.

Incremental Builds in Practice

The real power of project references shows in day-to-day development. When you run tsc --build --verbose, you see exactly what gets rebuilt and why. The compiler tracks a .tsbuildinfo file per project that records the state of the last successful build.

Here is what happens when you change one file in the shared package and rebuild. The compiler detects the change by comparing file hashes, rebuilds only shared, notices the apps depend on shared, and rebuilds them too:

texttext
$ tsc -b --verbose
Projects in scope: shared, app-web, app-api
Project 'shared' is out of date because output of 'src/user.ts' changed
Building project 'shared'...
Project 'app-web' is out of date because its dependency 'shared' changed
Building project 'app-web'...
Project 'app-api' is out of date because its dependency 'shared' changed
Building project 'app-api'...

If you change a file in app-web that does not affect its public API, only app-web rebuilds. The other packages are unaffected because their dependencies did not change. For more on structuring packages this way, see set package boundaries in TypeScript.

Common Mistakes

Forgetting composite in referenced projects. A referenced project must have composite enabled. Without it, tsc --build refuses to build the project and the error message is not always clear about the missing flag.

Putting all source in one package with many references. A package that references five other packages but has no source of its own should be the root solution tsconfig, not a leaf package. Leaf packages should contain actual source code.

Not setting declarationDir or outDir consistently. If two packages output to the same dist directory, declaration files collide. Each package needs its own output folder, typically a dist directory inside the package.

Importing from referenced projects without workspace linking. Project references handle types at compile time. At runtime, Node needs to find the actual JavaScript files. npm workspaces or path aliases in a bundler handle this second half.

For more on keeping types organized across packages, see share types across a TypeScript codebase.

Rune AI

Rune AI

Key Insights

  • Project references let TypeScript compile packages independently and only rebuild what changed.
  • Each package needs its own tsconfig with composite enabled and declaration generation turned on.
  • A root solution tsconfig with references to all packages provides a single tsc --build entry point.
  • Dependents consume .d.ts output files, not source, which enforces package boundaries.
  • Combine project references with npm workspaces for a complete monorepo setup without extra tools.
RunePowered by Rune AI

Frequently Asked Questions

What is a TypeScript project reference?

A project reference is a tsconfig setting that tells TypeScript one project depends on another. When project A references project B, TypeScript uses B's compiled declaration files instead of re-checking B's source code. This enables incremental, parallel builds across a monorepo.

Do I need a build tool like Turborepo or Nx to use TypeScript project references?

No. tsc --build handles dependency ordering and incremental compilation natively. However, tools like Turborepo and Nx add caching, task orchestration, and parallel execution across languages. They complement project references rather than replacing them.

Conclusion

TypeScript project references turn a monorepo from a slow, monolithic compile into a fast, modular build graph. Each package compiles independently, produces declaration files for its dependents, and only rebuilds when its own source changes. The result is a codebase that scales without slowing down.