Explain project references in TypeScript.
TypeScript project references provide a powerful mechanism for structuring large TypeScript projects, especially in monorepos. They allow you to break down your codebase into smaller, interconnected projects, improving build performance, code organization, and development experience.
What are Project References?
At its core, a project reference is a declaration within a tsconfig.json file that points to another tsconfig.json file. This creates a dependency relationship, allowing TypeScript to understand how different parts of your codebase relate to each other for compilation purposes.
They are designed to solve common challenges in large-scale projects, such as slow build times, difficulty managing dependencies between modules, and the need for clear separation of concerns in a monorepo setup.
Key Benefits
- Modularization: Break down a large application or library into smaller, independent components with clear boundaries.
- Improved Build Performance: TypeScript's build mode (
tsc -b) can incrementally build only the projects that have changed and their dependents, significantly speeding up compilation. - Enhanced IDE Experience: IDEs can better understand project dependencies, leading to more accurate go-to-definition, refactoring across project boundaries, and quicker type checking.
- Simplified Monorepo Management: Provides a native TypeScript solution for managing dependencies between packages within a monorepo, complementing tools like Yarn Workspaces or Lerna.
- Type Safety Across Boundaries: Ensures that type definitions align correctly when one project consumes another, preventing runtime type errors.
How to Configure Project References
To set up project references, you modify the tsconfig.json files for both the consuming project and the referenced project.
1. The Referenced Project (e.g., a shared utility library)
The project being referenced (the 'child' or 'dependency') must have composite: true in its compilerOptions. It's also highly recommended to enable declaration: true and declarationMap: true so that consuming projects can get type definitions and source mapping.
{
"compilerOptions": {
"composite": true, // Must be true for referenced projects
"declaration": true, // Generate .d.ts files
"declarationMap": true, // Generate .d.ts.map files
"outDir": "./dist", // Output directory for compiled files
"rootDir": "./src",
"strict": true,
"module": "commonjs",
"target": "es2016",
"esModuleInterop": true
},
"include": ["src"]
}
2. The Consuming Project (e.g., an application)
The project that uses the referenced project (the 'parent' or 'consumer') lists its dependencies in the references array within its tsconfig.json. Each entry in references points to the path of another tsconfig.json file.
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"module": "commonjs",
"target": "es2016",
"esModuleInterop": true,
"paths": { // Optional: for cleaner imports
"@shared-utils/*": ["../shared-utils/src/*"]
}
},
"include": ["src"],
"references": [
{ "path": "../shared-utils" } // Path to the tsconfig.json of the referenced project
]
}
Note: While the consuming project does not strictly require composite: true itself, it's often enabled for consistency, especially in monorepos where all packages might be built using tsc -b.
Building with Project References
TypeScript's 'build mode', invoked with tsc -b (or tsc --build), is specifically designed to work with project references. When you run tsc -b, TypeScript will:
- Discover all referenced projects.
- Topologically sort them based on dependencies.
- Build each project in the correct order, only recompiling projects whose source files or dependencies have changed.
You typically run tsc -b from the root of your monorepo or from the consuming project's directory. This command will recursively find and build all tsconfig.json files that are part of the dependency graph.
{
"name": "my-monorepo-root",
"private": true,
"scripts": {
"build": "tsc -b",
"watch": "tsc -b --watch"
}
}
Project References vs. Path Aliases
It's important to distinguish project references from path aliases (paths in compilerOptions).
- Path Aliases: A module resolution feature. They help TypeScript and bundlers locate modules at specific paths during development and bundling, making import statements cleaner (e.g.,
import { foo } from '@utils/foo';instead of../../utils/foo). They don't dictate build order. - Project References: A compilation feature. They define the build dependency graph between different
tsconfig.jsonprojects, enabling incremental builds and cross-project type checking. They manage how separate compilation units relate to each other.
Often, you'll use both: project references to manage the build flow and paths to simplify import statements within and across projects.
Important Considerations
- Circular Dependencies: TypeScript project references do not allow circular dependencies. If project A references B, B cannot reference A.
- Output Structure: Each referenced project typically compiles its output (JavaScript, declaration files) to its own
outDir. Consuming projects will then import from these compiled outputs. .tsbuildinfoFiles: Whencomposite: trueis enabled, TypeScript generates a.tsbuildinfofile next to thetsconfig.json. This file stores information about the build graph and enables fast incremental builds.- NoEmit for Root: A root
tsconfig.jsonthat only orchestrates builds might use"noEmit": trueif it doesn't have its own source files to compile.