TypeScript 101 ๐ท Performance โ Type Checking at Scale
A TypeScript project that compiles in two seconds is a joy. A TypeScript project that takes two minutes is a productivity tax paid every time a developer saves a file, runs a test, or pushes to CI. Performance is not a feature that gets added at the end. It is a property of how the project is structured, how the compiler is configured, and which tools are used for which jobs.
The TypeScript compiler has been engineered for scale since the beginning, but its default configuration is not optimized for large codebases. The --build mode, project references, and incremental compilation are the features that make a monorepo with hundreds of packages compile in seconds rather than minutes . The TypeScript 7.0 rewrite in Go delivered 8xโ12x speedups on full builds by moving the compiler to native code with shared-memory multithreading . But even with the fastest compiler, the project’s structure determines whether the compiler can be incremental.
Key point: The single most impactful performance optimization for a large TypeScript project is to split it into smaller projects connected by project references. Each project compiles independently, produces its own declaration files, and records its own build state in a .tsbuildinfo file. When one project changes, only that project and its dependents are rechecked. The rest of the codebase is untouched. This is the mechanism that makes monorepos compile in seconds instead of minutes .
Why type-checking performance matters at scale
A small project has one tsconfig.json, a few dozen files, and a compile time measured in seconds. A large project has hundreds of files, dozens of @types packages, and a compile time measured in minutes. The difference is not linear. TypeScript’s type checker must resolve every type, every import, and every declaration in the entire program. As the program grows, the work grows faster than the line count.
The full-program-check problem. By default, tsc checks the entire program on every invocation. Every file is parsed, every import is resolved, every type is inferred. If the project has 5,000 files and the developer changes one, the compiler still checks all 5,000. The --incremental flag stores the state of the previous compilation in a .tsbuildinfo file and rechecks only what changed . But --incremental on a single project is limited: it can skip files that did not change, but it cannot skip the parts of the program that depend on a changed file.
The monorepo problem. In a monorepo with dozens of packages, a change to a shared package invalidates every package that depends on it. Without project references, the compiler rechecks the entire monorepo. With project references, the compiler checks the shared package, then the packages that directly depend on it, and skips the packages that do not. The dependency graph determines the order and the scope of the recheck .
The declaration-emit problem. When a project produces declaration files (.d.ts), the compiler must infer the types of every exported symbol. This inference requires the full type checker. The isolatedDeclarations flag requires explicit type annotations on exported symbols, which allows the declaration files to be generated per-file without the type checker. This is what enables parallel declaration emit in bundlers .
The editor problem. The language server (tsserver) runs the same type checker that tsc runs. A slow tsc means a slow editor. Every keystroke triggers a recheck, and the recheck must be fast enough to feel instant. TypeScript 7.0’s LSP-based server and multithreading reduced the time to open a file with an error in the VS Code codebase from 17.5 seconds to under 1.3 seconds .
The trade-off. Performance optimizations add configuration. Project references require composite: true, declaration: true, and a references array in each tsconfig.json. They require a root tsconfig.json that serves as the entry point for tsc --build. They require the dependency graph in package.json to match the references array. The configuration is more complex, but the payoff is a build that scales with the number of changed files rather than the total size of the codebase.
a. Project References and Build Mode
Project references are the primary tool for scaling TypeScript. They allow a codebase to be divided into multiple projects, each with its own tsconfig.json, its own compilation, and its own build state .
The references property in tsconfig.json lists the projects that the current project depends on:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist"
},
"references": [
{ "path": "../shared" },
{ "path": "../ui" }
],
"include": ["src/**/*"]
}
The composite: true flag enables incremental compilation for the project. It requires declaration: true, because the referenced project exposes its .d.ts files to the projects that depend on it . The .tsbuildinfo file records the state of the last compilation. When tsc --build runs, it reads the .tsbuildinfo file and compares it against the current source. If nothing has changed in a project or its dependencies, the project is skipped .
The root tsconfig.json is the entry point for tsc --build:
{
"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 tsc --build runs, it processes the projects in dependency order: shared first, then ui, then analytics (which depends on ui and shared), and finally dashboard (which depends on all of them). Projects that do not depend on each other can be built in parallel .
The performance gain is dramatic. A change to shared invalidates shared, ui, analytics, and dashboard. A change to analytics invalidates only analytics and dashboard. The rest of the monorepo is untouched. The second tsc --build after a change is faster than the first because the .tsbuildinfo files record what has already been compiled .
b. isolatedDeclarations and Parallel Emit
The isolatedDeclarations flag, introduced in TypeScript 5.5, requires that exported symbols have explicit type annotations. This requirement allows declaration files to be generated without the type checker, which in turn allows declaration emit to be parallelized .
Without isolatedDeclarations, generating a .d.ts file requires the full type checker to infer the types of exported symbols. This inference may depend on types imported from other modules, which means the declaration emit cannot happen until the entire program has been checked.
With isolatedDeclarations, the requirement is that every exported symbol has an explicit, locally-inferable type. The annotation can be a type reference, a union, an interface, or a primitive. What it cannot be is an inferred return type that depends on the types of other modules .
// Before isolatedDeclarations โ return type inferred
export function buildClient(config: ClientConfig) {
return { send: (req: ApiRequest) => fetch(config.url, req) };
}
// After isolatedDeclarations โ explicit return type
export function buildClient(config: ClientConfig): ApiClient {
return { send: (req: ApiRequest) => fetch(config.url, req) };
}
The isolatedDeclarations flag does not make the code compile faster by itself. It makes the declaration emit parallelizable, which allows tools like bundlers to generate .d.ts files per-file in parallel rather than waiting for the full program check . In a monorepo, this removes the cross-package type-check bottleneck from declaration emit and turns it into a per-file operation .
The flag is most valuable in monorepos where declaration files are the contract between packages. Each package’s .d.ts file can be generated independently, and downstream packages can read the .d.ts file without re-parsing the source .
c. Diagnosing Performance Issues
When a project is slow, the first step is to find out why. TypeScript provides several diagnostic flags that report what the compiler is doing and where the time is going.
The --extendedDiagnostics flag reports the time spent in each phase of the compiler: parsing, binding, checking, emitting. A high “Check time” indicates that the type checker is the bottleneck. A high “Program time” or “I/O Read time” indicates that the compiler is reading more files than it should .
The --listFilesOnly flag reports every file that the compiler is including in the program. If node_modules or test files are being included unexpectedly, the include and exclude settings in tsconfig.json are misconfigured. The --explainFiles flag explains why each file was included .
The --traceResolution flag reports the precise steps taken to resolve every import. This is used to diagnose module resolution issues that cause the compiler to look in the wrong places or fail to find a module .
The --generateTrace flag produces a performance trace that can be analyzed in Chrome’s about://tracing UI or with the @typescript/analyze-trace utility. The trace shows the call stack of the compiler and identifies the hot spots .
For editor performance, the typescript.tsserver.enableTracing setting in VS Code produces a trace from the language server. The TSS_LOG environment variable captures the tsserver protocol log, which shows every request and response .
The common causes of slow type checking are:
Misconfigured include and exclude. If node_modules is included, the compiler checks every .d.ts file in every dependency. The exclude array should include node_modules and hidden directories .
Missing skipLibCheck. The skipLibCheck option skips type checking of declaration files (.d.ts). It is safe to enable in almost every project and significantly reduces compile time .
Computationally intensive @types packages. Some @types packages contain complex type definitions that are expensive to check. Upgrading to a newer version or replacing the package can help .
No project references. A single tsconfig.json for a large codebase forces a full-program check on every invocation. Project references split the work into smaller units that can be checked incrementally .
No incremental compilation. The --incremental flag stores the state of the previous compilation in a .tsbuildinfo file. Without it, every invocation starts from scratch .
Complete Example Session
This session converts a single-project TypeScript codebase into a project-references setup and measures the performance difference.
// ============================================
// PART 1: THE SINGLE-PROJECT SETUP
// ============================================
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"exclude": ["node_modules", "**/.*/"]
},
"include": ["packages/**/*", "apps/**/*"]
}
// Run: tsc --noEmit --extendedDiagnostics
// Files: 4500
// Parse time: 1.2s
// Bind time: 0.8s
// Check time: 18.4s
// Total time: 21.5s
// ============================================
// PART 2: SPLIT INTO PROJECTS
// ============================================
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
// 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/analytics/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true
},
"references": [
{ "path": "../shared" },
{ "path": "../ui" }
],
"include": ["src/**/*"]
}
// 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" },
{ "path": "../../packages/analytics" }
],
"include": ["src/**/*"]
}
// ============================================
// PART 3: THE ROOT CONFIGURATION
// ============================================
// tsconfig.json
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/ui" },
{ "path": "./packages/analytics" },
{ "path": "./apps/dashboard" }
]
}
// ============================================
// PART 4: THE FIRST BUILD
// ============================================
// npx tsc --build
// Compiles shared, then ui, then analytics, then dashboard.
// Each project writes its own .tsbuildinfo file.
// Time: 22.1s (similar to single-project because everything compiles)
// ============================================
// PART 5: THE SECOND BUILD (NO CHANGES)
// ============================================
// npx tsc --build
// Reads the .tsbuildinfo files.
// Detects that nothing has changed.
// Skips all projects.
// Time: 0.3s
// ============================================
// PART 6: CHANGE A FILE IN shared
// ============================================
// Edit packages/shared/src/utils.ts
// npx tsc --build
// Rechecks shared.
// Rechecks ui (depends on shared).
// Rechecks analytics (depends on shared and ui).
// Rechecks dashboard (depends on all).
// The change cascades through the dependency graph.
// Time: 8.2s
// ============================================
// PART 7: CHANGE A FILE IN analytics
// ============================================
// Edit packages/analytics/src/reports.ts
// npx tsc --build
// Rechecks analytics.
// Rechecks dashboard (depends on analytics).
// Skips shared and ui (they do not depend on analytics).
// Time: 3.1s
// ============================================
// PART 8: ENABLE isolatedDeclarations
// ============================================
// Add to each project's tsconfig.json:
{
"compilerOptions": {
"isolatedDeclarations": true
}
}
// Now every exported symbol must have an explicit type annotation.
// The declaration emit can be parallelized.
// ============================================
// PART 9: THE EXTENDED DIAGNOSTICS
// ============================================
// tsc --noEmit --extendedDiagnostics
// Files: 4500
// Parse time: 0.4s
// Bind time: 0.2s
// Check time: 2.8s
// Total time: 3.6s
// The check time is much lower because the compiler
// only checks what has changed.
// ============================================
// PART 10: THE CI PIPELINE
// ============================================
// .github/workflows/ci.yml
- run: npx tsc --build --clean
- run: npx tsc --build
// The clean step removes the .tsbuildinfo files.
// The build step compiles everything from scratch.
// In a monorepo with many projects, this is faster
// than a single-project full check because of
// incremental compilation within each project.
The ten parts cover the single-project setup, splitting into projects, the root configuration, the first build, the second build, changing a file in shared, changing a file in analytics, enabling isolatedDeclarations, the extended diagnostics, and the CI pipeline.
Quick Reference
The Performance Features
| Feature | Purpose | Configuration |
|---|---|---|
| Project references | Split into smaller projects | references array |
| Build mode | Compile projects in dependency order | tsc --build |
| Incremental | Store state in .tsbuildinfo | composite: true |
| Declaration maps | Cross-project go-to-definition | declarationMap: true |
| isolatedDeclarations | Parallel declaration emit | isolatedDeclarations: true |
| skipLibCheck | Skip checking .d.ts files | skipLibCheck: true |
The Diagnostic Flags
| Flag | Purpose |
|---|---|
--extendedDiagnostics | Time spent in each compiler phase |
--listFilesOnly | Every file included in the program |
--explainFiles | Why each file was included |
--traceResolution | Import resolution steps |
--generateTrace | Full performance trace |
--showConfig | Resolved compiler configuration |
The Common Causes of Slow Type Checking
| Cause | Fix |
|---|---|
node_modules included | exclude: ["node_modules"] |
skipLibCheck missing | skipLibCheck: true |
| No project references | Split into projects |
| No incremental | composite: true |
Expensive @types package | Upgrade or replace |
| Full-program check | Use tsc --build |
The tsconfig for Project References
| Setting | Value | Purpose |
|---|---|---|
composite | true | Enable incremental compilation |
declaration | true | Emit .d.ts files |
declarationMap | true | Enable cross-project navigation |
rootDir | ./src | Predictable declaration paths |
outDir | ./dist | Output directory |
references | [{ "path": "../shared" }] | Dependency graph |
Best Practices
โ Do This:
// Split the codebase into projects
{ "references": [{ "path": "../shared" }] } // โ
// Enable composite and declaration for each project
{ "compilerOptions": { "composite": true, "declaration": true } } // โ
// Enable skipLibCheck
{ "compilerOptions": { "skipLibCheck": true } } // โ
# Use tsc --build instead of tsc
npx tsc --build // โ
// Use isolatedDeclarations for parallel emit
{ "compilerOptions": { "isolatedDeclarations": true } } // โ
โ Don’t Do This:
// Don't include node_modules
{ "include": ["**/*"] } // without exclude // โ
// Don't skip declaration in composite projects
{ "composite": true } // without declaration // โ
# Don't run tsc without --build in a project-references setup
npx tsc // does not use project references // โ
// Don't mismatch references and package.json dependencies
// The references array must mirror package.json. // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
tsc --build doesn’t build | files: [] missing in root | Add files: [] |
| Errors about missing files | include does not cover all files | Fix include patterns |
composite requires declaration | Declaration not enabled | Add declaration: true |
| References not used | tsc instead of tsc --build | Use --build |
| Stale type information | .tsbuildinfo out of date | Run tsc --build --clean |
| Slow editor | Large project, no incremental | Use project references |
Real-World Examples
1. Project References
{ "references": [{ "path": "../shared" }] }
2. Composite Project
{ "compilerOptions": { "composite": true, "declaration": true } }
3. Root Build Configuration
{ "files": [], "references": [{ "path": "./packages/shared" }] }
4. Build Mode
npx tsc --build
5. Clean Build
npx tsc --build --clean
6. Extended Diagnostics
npx tsc --noEmit --extendedDiagnostics
7. List Files
npx tsc --listFilesOnly
8. Explain Files
npx tsc --explainFiles > explanations.txt
9. Trace Resolution
npx tsc --traceResolution > resolutions.txt
10. Generate Trace
npx tsc --generateTrace tracing_output
Visual
The Single-Project vs Project-References
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SINGLE PROJECT โ
โ โ
โ tsconfig.json โ
โ โโ All files (4500) โ
โ โ
โ Change one file: โ
โ โโ Check all 4500 files โ
โ โ
โ Time: 21.5s โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ PROJECT REFERENCES โ
โ โ
โ shared (500 files) โ
โ โโ .tsbuildinfo โ
โ ui (1200 files) โ depends on shared โ
โ โโ .tsbuildinfo โ
โ analytics (800 files) โ depends on shared, uiโ
โ โโ .tsbuildinfo โ
โ dashboard (2000 files) โ depends on all โ
โ โโ .tsbuildinfo โ
โ โ
โ Change one file in shared: โ
โ โโ Check shared, ui, analytics, dashboard โ
โ โโ Time: 8.2s โ
โ โ
โ Change one file in analytics: โ
โ โโ Check analytics, dashboard โ
โ โโ Time: 3.1s โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Dependency Graph
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ DEPENDENCY GRAPH โ
โ โ
โ shared โ
โ โโ ui โ
โ โ โโ analytics โ
โ โ โโ dashboard โ
โ โโ analytics โ
โ โโ dashboard โ
โ โ
โ Build order: โ
โ 1. shared โ
โ 2. ui โ
โ 3. analytics โ
โ 4. dashboard โ
โ โ
โ Parallel: ui and analytics could build โ
โ in parallel if they did not depend on โ
โ the same upstream. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The isolatedDeclarations Parallel Emit
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ isolatedDeclarations โ
โ โ
โ Without: โ
โ .d.ts emit requires full program check โ
โ โโ Sequential, slow โ
โ โ
โ With: โ
โ .d.ts emit is per-file, parallelizable โ
โ โโ Bundler can emit in parallel โ
โ โโ No cross-package type-check bottleneck โ
โ โ
โ Requires: โ
โ โโ Explicit return types on exports โ
โ โโ Explicit types on exported variables โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Diagnostic Decision Tree
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ DIAGNOSING SLOW TYPE CHECKING โ
โ โ
โ tsc --extendedDiagnostics โ
โ โ โ
โ โโ High I/O Read time โ
โ โ โโ Fix include/exclude โ
โ โ โ
โ โโ High Program time โ
โ โ โโ Check listFilesOnly โ
โ โ โโ Use explainFiles โ
โ โ โ
โ โโ High Check time โ
โ โ โโ Split into project references โ
โ โ โโ Enable skipLibCheck โ
โ โ โโ Upgrade @types packages โ
โ โ โ
โ โโ High Emit time โ
โ โโ Enable isolatedDeclarations โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Primary optimization | Project references |
| Build mode | tsc --build |
| Incremental state | .tsbuildinfo |
| Required for composite | declaration: true |
| Cross-project navigation | declarationMap: true |
| Parallel declaration emit | isolatedDeclarations: true |
| Skip declaration checking | skipLibCheck: true |
| Diagnostic flag | --extendedDiagnostics |
| Trace flag | --generateTrace |
| TypeScript 7.0 speedup | 8xโ12x on full builds |
Key takeaways:
- Project references split a large codebase into smaller projects. Each project has its own
tsconfig.json, its own compilation, and its own.tsbuildinfofile. A change to one project invalidates only that project and its dependents. The rest of the codebase is skipped . - Build mode (
tsc --build) processes projects in dependency order. Thereferencesarray in each project’stsconfig.jsondefines the graph.tsc --buildreads the.tsbuildinfofiles and skips projects that have not changed . composite: trueis required for referenced projects. It enables incremental compilation and requiresdeclaration: trueso that the project’s output can be consumed by the projects that depend on it .isolatedDeclarationsenables parallel declaration emit. By requiring explicit type annotations on exported symbols, it allows.d.tsfiles to be generated per-file without the type checker. This removes the cross-package type-check bottleneck in monorepos .- Diagnostic flags identify the bottleneck.
--extendedDiagnosticsreports the time in each compiler phase.--listFilesOnlyand--explainFilesshow what the compiler is including.--traceResolutionshows how imports are resolved.--generateTraceproduces a full performance trace . - The most common causes of slow type checking are misconfigured
include/exclude, missingskipLibCheck, and the absence of project references. Fixing these three issues resolves most performance problems . - TypeScript 7.0’s native Go compiler delivered 8xโ12x speedups on full builds. The speedup comes from native code, shared-memory multithreading, and a more efficient traversal of the syntax graph. The
--checkersand--buildersflags tune the parallelism .
Remember: Type-checking performance at scale is not about making the compiler faster. It is about giving the compiler less work to do. Project references split the work into smaller units. Incremental compilation skips what has not changed. isolatedDeclarations enables parallel emit. skipLibCheck skips what does not matter. The compiler is fast. The question is whether the project is structured to let it be fast. A single tsconfig.json for a 5,000-file codebase forces a full-program check on every save. The same codebase split into ten projects with project references rechecks only what changed. The configuration is more complex. The performance is worth it.
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!