TypeScript Performance
Speed up tsc compile times with Project References, incremental builds, type-level optimizations, and profiling slow type checks.
Why TypeScript Performance Matters
In large codebases, TypeScript type checking can become a bottleneck — both in local development (editor lag) and CI (slow builds). The type checker does real work: it infers types, resolves generics, evaluates conditional types, and checks every assignment. When that work piles up across hundreds of files, compile times can stretch from seconds to minutes. Understanding what makes TypeScript slow lets you fix it before it becomes a problem, and the fixes are usually configuration changes rather than code rewrites.
Incremental Builds
The fastest win available with a single config change. Without incremental builds, TypeScript recompiles and rechecks every file on every run. With incremental: true, TypeScript writes a .tsbuildinfo cache file after each build that records which files were checked and what their types were. On subsequent builds, only files that changed — and files that depend on them — are rechecked. In a large project, this can reduce a 60-second full build to a 3-second incremental one.
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo" // explicit path keeps it out of src/
}
}
Add .tsbuildinfo to .gitignore. On first run TypeScript writes a cache file; subsequent builds only recheck changed files.
For watch mode, incremental is enabled automatically. For CI, commit the cache file to get fast rebuilds, or use a CI cache layer.
Project References
Project References are the right tool for monorepos and large multi-package projects. They let TypeScript build packages in parallel, skip unchanged packages completely, and share type information across packages via declaration files — without needing to re-type-check a package that hasn’t changed. The setup requires each referenced package to have composite: true and declaration: true, which tells TypeScript it can be used as a dependency by other packages.
packages/
├── shared/ # utilities and types
├── api/ # backend, depends on shared
└── web/ # frontend, depends on shared
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true, // required — marks this as a referenced project
"declaration": true, // required — other packages consume the .d.ts files
"declarationMap": true, // enables go-to-definition to jump to source
"outDir": "./dist"
}
}
// packages/api/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"references": [
{ "path": "../shared" } // TypeScript builds shared first if needed
]
}
// tsconfig.json (root — build entry point only, no source files)
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
Build all projects:
npx tsc --build # full build
npx tsc --build --watch # watch mode
npx tsc --build --clean # clean all outputs
TypeScript builds shared first, then api and web in parallel.
skipLibCheck
The single highest-impact tsconfig option for most projects. TypeScript normally type-checks every .d.ts file it loads, including everything in node_modules. Since you can’t fix type errors in your dependencies, there’s no value in checking them — but it takes real time. Setting skipLibCheck: true skips all declaration file checking and can cut compile time by 30–50% on projects with many dependencies.
{
"compilerOptions": {
"skipLibCheck": true
}
}
isolatedModules
Enabling isolatedModules requires every file to be independently transpilable without cross-file type information. This restriction is what enables fast single-file transpilers like esbuild and SWC to handle the TS → JS conversion step, while tsc --noEmit handles type checking separately. Splitting the two jobs — fast transpilation for dev, separate type check for CI — gives you the fastest possible feedback loops without sacrificing safety.
{
"compilerOptions": {
"isolatedModules": true
}
}
A common fast build pipeline that separates type checking from transpilation:
{
"scripts": {
"typecheck": "tsc --noEmit", // type check only — no output
"build": "esbuild src/index.ts --bundle --outfile=dist/index.js" // transpile only — no type check
}
}
esbuild is 10–100x faster than tsc at transpilation because it skips type checking entirely.
Profiling Slow Type Checks
Before optimizing, measure. TypeScript 4.2+ includes a built-in tracing tool that records how long each type check takes and which files are responsible. This tells you exactly where to focus instead of guessing.
npx tsc --generateTrace ./trace-output --noEmit
Then open the trace in Chrome DevTools:
- Open
chrome://tracing - Load
trace-output/trace.json - Look for long
checkSourceFilespans
Or use the @typescript/analyze-trace package for a plain-text summary:
npm install -g @typescript/analyze-trace
npx tsc --generateTrace ./trace --noEmit
npx analyze-trace ./trace
It prints a summary of the slowest type checks:
Hot spots:
src/api/schema.ts (847ms) — large union type with 200+ members
src/types/generated.ts (512ms) — recursive conditional type
Avoiding Slow Type Patterns
Certain TypeScript patterns force the type checker to do exponentially more work. The patterns below are the most common culprits found during profiling. The fixes don’t change runtime behavior — only the type-level representation.
Avoid large union types — each member must be checked on every union operation:
// Slow — TypeScript checks each member on every union operation
type Route =
| "/users"
| "/users/:id"
| "/posts"
| "/posts/:id"
// ... 200 more routes
// Faster — template literal type generates the same union lazily
type ResourceId = "users" | "posts" | "comments";
type Route = `/${ResourceId}` | `/${ResourceId}/:id`;
Avoid deeply recursive types — TypeScript must evaluate every level of recursion eagerly:
// Slow — TypeScript must evaluate many levels of recursion
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// Faster — limit recursion depth or use a library (type-fest)
type DeepReadonly<T, Depth extends number = 5> = Depth extends 0
? T
: { readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K], [-1, 0, 1, 2, 3, 4][Depth]>
: T[K] };
Prefer interfaces over type intersections for objects — interface extension is lazily evaluated; type intersections are resolved eagerly:
// Slower — TypeScript must eagerly resolve the intersection at definition time
type User = BaseEntity & { name: string } & { email: string };
// Faster — interface extends is lazily evaluated when the type is used
interface User extends BaseEntity {
name: string;
email: string;
}
Avoid export * in barrel files — wildcard re-exports force TypeScript to load and resolve every file in the barrel to determine what’s exported:
// Slow — TypeScript must resolve every file before knowing what's available
export * from "./users";
export * from "./posts";
export * from "./comments";
// ... 50 more
// Faster — explicit re-exports tell TypeScript exactly what to load
export { UserService, type User } from "./users";
export { PostService, type Post } from "./posts";
Type Checking in CI
Running tsc in CI as part of the build step means waiting for both type checking and file emission before getting feedback. Separating them into two parallel jobs gives faster CI results: the type check job gives a pass/fail signal quickly, while the build job runs independently. Using --noEmit for the type check step is also faster because TypeScript skips writing output files.
# .github/workflows/ci.yml
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Type check
run: npx tsc --noEmit # check types only, skip emitting files
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: Build
run: npm run build
strictMode Performance Impact
strict: true enables checks that require more work from the type checker. In extreme cases, selectively disabling individual strict flags can help, but only after profiling confirms the specific flag is the cause. Disabling flags indiscriminately undermines the safety guarantees that make TypeScript valuable, so treat this as a last resort for files that are genuinely unmaintainable otherwise.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": false // disables one specific expensive check
}
}
Module Resolution Performance
The moduleResolution setting affects how many file system lookups TypeScript makes per import. Using bundler for projects with Vite, webpack, or esbuild skips resolution strategies that don’t apply to bundled projects, reducing unnecessary I/O during type checking.
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler" // skips irrelevant resolution strategies
}
}
Practical Checklist
For a project experiencing slow TypeScript compilation, work through these steps in order — each one builds on the last:
- Enable
incremental: trueandskipLibCheck: true— immediate wins with no downside - Run
tsc --generateTraceand identify the hotspot files before optimizing anything - Check for large union types (200+ members) or deep recursive types in the hot files
- Consider Project References if the project is actually multiple logical packages
- Separate
tsc --noEmit(type check) from esbuild/swc (transpile) for the fastest possible dev loop - Use
isolatedModules: trueto ensure compatibility with fast transpilers