TypeScript Performance Optimization — Code Speed and Bundle Size
In this tutorial, you will learn about TypeScript Performance Optimization. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript performance optimization spans compile-time (build speed, type-checking cost) and runtime (bundle size, code efficiency) — understanding both dimensions ensures your TypeScript applications are fast to build and fast to execute.
What You'll Learn
- Compiler performance flags
- Project references for incremental builds
- Type inference cost management
- Tree shaking and dead code elimination
- Lazy loading patterns
- Bundle size optimization
Why It Matters
TypeScript's compiler can become a bottleneck in large projects — 100,000+ file codebases can take minutes to type-check. At runtime, TypeScript's type erasure means you only pay for the JavaScript you emit, but certain patterns create unnecessary overhead.
Real-World Use
The Doda Browser codebase had a 12-minute TypeScript build. By applying project references, incremental builds, and optimizing type-heavy modules, they reduced check times to under 2 minutes while maintaining full type safety.
Learning Path
flowchart LR A[Pattern Matching] --> B[Performance] B --> C[Project: REST API] B --> D[You Are Here] C --> E[Project: Dashboard] D --> F[Migration from JS]
Compiler Performance Flags
Several tsconfig.json options directly impact build speed:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
| Flag | Impact |
|---|---|
incremental |
Rebuild only changed files (saves 60-80% on subsequent builds) |
skipLibCheck |
Skip type-checking .d.ts files (huge savings with many dependencies) |
isolatedModules |
Faster non-comprehensive check (compatible with esbuild) |
verbatimModuleSyntax |
Avoids unnecessary module transformations |
Measuring Build Time
# Time a full build
time npx tsc --noEmit
# With incremental
time npx tsc --noEmit --incremental
# Generate performance trace
npx tsc --generateTrace trace
npm install -g @typescript/analyze-trace
npx analyze-trace trace
The performance trace shows which files and types take the longest to check.
Project References for Large Codebases
Split your codebase into smaller projects that TypeScript checks independently:
// tsconfig.json (root)
{
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
],
"files": []
}
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
// packages/api/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [{ "path": "../core" }],
"include": ["src"]
}
How it works: TypeScript builds core first, then api uses the pre-built .d.ts files from core instead of re-checking core's source. This prevents cascading re-checks.
Type Inference Cost Management
Complex type inference slows both the compiler and your editor:
// ❌ Expensive — deep nested inference
const result = await someFunction()
.then(data => process(data))
.then(transformed => transform(transformed))
.then(final => finalize(final));
// ✅ Fast — explicit typing breaks the chain
interface FinalResult {
id: string;
status: 'done' | 'pending';
}
const result: FinalResult = await someFunction()
.then(data => process(data))
.then(transformed => transform(transformed))
.then(final => finalize(final));
Expensive Type Patterns
// ❌ Recursive types that aren't memoized
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
// ✅ Limit recursion depth for common cases
type ShallowReadonly<T> = {
readonly [P in keyof T]: T[P];
};
// ❌ Large template literal types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRoutes<T extends string> = `/api/${T}/${HttpMethod}`;
// TypeScript expands this combinatorially
// ✅ Narrow the literal types
type ApiRoute = '/api/users/GET' | '/api/posts/GET' | '/api/posts/POST';
Tree Shaking and Dead Code
TypeScript emits all code by default. Use ES modules for tree shaking:
// utils.ts
export function usedFunction() {
return 'This is included';
}
export function unusedFunction() {
return 'This is removed by tree shaking';
}
// main.ts
import { usedFunction } from './utils';
console.log(usedFunction());
When bundled with esbuild or webpack, unusedFunction is eliminated from the final bundle.
Isolated Modules for Better Tree Shaking
{
"compilerOptions": {
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
verbatimModuleSyntax preserves import type statements, making it clear to bundlers which imports can be removed:
// This import is fully removed at runtime
import type { User } from './types';
// This import is kept
import { getUser } from './api';
Lazy Loading Patterns
Split large modules at natural boundaries:
// ❌ Eager — all routes load upfront
import { HeavyDashboard } from './dashboard';
import { HeavyReports } from './reports';
import { HeavySettings } from './settings';
// ✅ Lazy — load on demand
async function loadComponent(route: string) {
switch (route) {
case 'dashboard':
const { HeavyDashboard } = await import('./dashboard');
return HeavyDashboard;
case 'reports':
const { HeavyReports } = await import('./reports');
return HeavyReports;
case 'settings':
const { HeavySettings } = await import('./settings');
return HeavySettings;
}
}
Route-Based Code Splitting with React
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
Each lazy-loaded page is a separate chunk loaded only when the user navigates to it.
Bundle Size Optimization
Track bundle size with tsc --showConfig and analyze with tools:
# Check what TypeScript outputs
npx tsc --showConfig
# Analyze bundle with source-map-explorer
npm install --save-dev source-map-explorer
npx source-map-explorer dist/bundle.js
# Or use the built-in analyze mode in esbuild
npx esbuild src/main.ts --bundle --analyze
Dead Type Elimination
Types are erased at compile time — but type imports can inflate declaration files:
// ❌ Generates larger .d.ts
export function processItem(item: {
id: string;
name: string;
metadata: Record<string, unknown>;
}): void;
// ✅ Uses an interface — same size, but reusable
export interface ProcessItemInput {
id: string;
name: string;
metadata: Record<string, unknown>;
}
export function processItem(item: ProcessItemInput): void;
Runtime Performance Patterns
TypeScript's type erasure means emitted JavaScript is what runs. But certain TypeScript patterns create slow JavaScript:
// ❌ Expensive — spread creates shallow copies
function updateUser(user: User, changes: Partial<User>): User {
return { ...user, ...changes };
}
// ✅ Fast — mutation is faster when safe
function updateUser(user: User, changes: Partial<User>): User {
user.name = changes.name ?? user.name;
user.email = changes.email ?? user.email;
return user;
}
// ❌ Expensive — conditional spread in hot loops
items.forEach(item => {
config.push({
id: item.id,
...(item.active && { activeAt: Date.now() }),
});
});
// ✅ Fast — avoid conditional spread in loops
items.forEach(item => {
const entry: ConfigEntry = { id: item.id };
if (item.active) {
entry.activeAt = Date.now();
}
config.push(entry);
});
Common Mistakes
1. Enabling all strict checks without incremental builds
Strict mode + no incremental = slow builds. Always pair strict checks with incremental: true.
2. Deeply nested mapped types in hot modules
Complex mapped types (like DeepPartial<T>) are checked every time the module is compiled. Extract them to a shared module.
3. Not using skipLibCheck in large projects
Type checking node_modules type definitions for thousands of packages is slow. Libraries are tested by their authors — skip their checks.
4. Single tsconfig for monolithic codebase
A single tsconfig means every file is re-checked on every build. Use project references to split.
5. Excessive conditional types in public API surfaces
Conditional types like Extract<T, U> are checked at every call site. If the type can be computed once, pre-compute it.
6. Not analyzing the performance trace
TypeScript's --generateTrace reveals the specific files and types causing slow checks. Use it before optimizing.
7. Optimizing prematurely
Profile first, then optimize. Many perceived TypeScript performance issues are negligible in practice — measure before fixing.
Practice Questions
What does
incremental: truedo? Caches previous compilation results and only re-checks changed files and their dependencies, reducing subsequent build times by 60-80%.How do project references improve build time? They split the codebase into independent projects. Changes to one project only trigger re-checks in dependent projects, not the entire codebase.
What is
skipLibCheckand when should you use it? It skips type-checking.d.tsdeclaration files. Use it in production builds — library type definitions are presumed correct.How does
isolatedModulesaffect compilation? It enables per-file transpilation (used by esbuild, ts-jest) without cross-file type inference. Faster but less comprehensive type checking.What's the most impactful single optimization for build speed? Enabling
incremental: true— it's a single flag that dramatically improves developer iteration time.
Challenge
Profile a TypeScript project with --generateTrace, analyze the trace with analyze-trace, identify the three slowest files or type operations, and optimize them with explicit typing, project references, or type extraction.
FAQ
Mini Project
Optimize a TypeScript Monorepo build pipeline:
- Project references: Split into core, api, web, and shared packages
- Incremental builds: Enable across all sub-projects
- Build trace: Generate and analyze a performance trace
- CI optimization: Different tsconfig for CI (full check) vs development (fast check)
- Bundle analysis: Use source-map-explorer to identify large modules and lazy-load them
What's Next
You've optimized TypeScript performance. Now apply everything you've learned by building a complete REST API with {{< ref "55-project-rest-api" >}}, a React dashboard with {{< ref "56-project-react-dashboard" >}}, or a CLI tool with {{< ref "57-project-cli-tool" >}}.
For Migration strategies, see {{< ref "58-migration-from-js" >}}.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro