TypeScript

Performance & Bundle Optimization

38 min Lesson 28 of 40

Understanding Bundle Size and Performance

TypeScript is erased at runtime, but your configuration choices significantly impact bundle size, tree-shaking effectiveness, and overall application performance. This lesson explores optimization techniques for production builds.

Type-Only Imports and Exports

Type-only imports are removed during compilation and don't create runtime dependencies:

// ❌ Bad: Creates runtime import import { User } from './types'; function getUser(): User { return { id: 1, name: 'John' }; } // ✓ Good: Type-only import (removed at runtime) import type { User } from './types'; function getUser(): User { return { id: 1, name: 'John' }; } // ✓ Also good: Inline type import import { type User, fetchUser } from './api';

Type-only exports for re-exporting:

// types.ts export interface User { id: number; name: string; } export interface Product { id: number; title: string; } // index.ts - ❌ Bad: Creates runtime dependency export { User, Product } from './types'; // index.ts - ✓ Good: Type-only export export type { User, Product } from './types';
Note: Use import type and export type to make type-only imports explicit. This helps bundlers tree-shake more effectively and makes your intent clear.

The isolatedModules Flag

Enable isolatedModules for better compatibility with single-file transpilers:

{ "compilerOptions": { "isolatedModules": true } }

This flag enforces restrictions that prevent patterns that can't be correctly interpreted by single-file transpilers like Babel, esbuild, or swc:

// ❌ Error with isolatedModules: Re-exporting a type needs explicit type keyword export { User } from './types'; // ✓ OK export type { User } from './types'; export { type User } from './types'; // Also OK // ❌ Error: const enums are not supported const enum Direction { Up, Down } // ✓ OK: Regular enum enum Direction { Up, Down } // ❌ Error: Cannot export only a type declaration export interface User { } // ✓ OK: Export with implementation export interface User { } export const createUser = () => ({ id: 1, name: 'test' });

Const Enums and Tree Shaking

Const enums are inlined during compilation:

// Regular enum (creates runtime object) enum Direction { Up = 'UP', Down = 'DOWN', Left = 'LEFT', Right = 'RIGHT' } const move = Direction.Up; // Compiled output includes the enum object: var Direction; (function (Direction) { Direction["Up"] = "UP"; Direction["Down"] = "DOWN"; Direction["Left"] = "LEFT"; Direction["Right"] = "RIGHT"; })(Direction || (Direction = {})); const move = Direction.Up;
// Const enum (inlined at compile time) const enum Direction { Up = 'UP', Down = 'DOWN', Left = 'LEFT', Right = 'RIGHT' } const move = Direction.Up; // Compiled output is inlined: const move = "UP"; // No enum object at runtime!
Warning: Const enums are not compatible with isolatedModules because single-file transpilers can't inline values across files. Use regular enums or union types instead if using modern bundlers.

Union Types vs Enums for Better Performance

Union types have zero runtime cost:

// ❌ Runtime overhead enum Status { Pending = 'PENDING', Approved = 'APPROVED', Rejected = 'REJECTED' } // ✓ Zero runtime cost type Status = 'PENDING' | 'APPROVED' | 'REJECTED'; // Use with const object for value access const STATUS = { Pending: 'PENDING', Approved: 'APPROVED', Rejected: 'REJECTED' } as const; type Status = typeof STATUS[keyof typeof STATUS]; function updateStatus(status: Status): void { console.log(status); } updateStatus(STATUS.Pending); // Type-safe with autocomplete

Tree Shaking Configuration

Optimize for tree shaking in tsconfig.json:

{ "compilerOptions": { // Use ES modules for best tree-shaking "module": "ESNext", "target": "ES2020", // Enable for Babel/esbuild/swc compatibility "isolatedModules": true, // Keep import/export statements "esModuleInterop": true, "allowSyntheticDefaultImports": true, // Don't preserve const enums (use regular enums/unions) "preserveConstEnums": false, // Remove comments to reduce size "removeComments": true, // Don't emit decorator metadata unless needed "emitDecoratorMetadata": false } }

Side Effects and package.json

Mark modules as side-effect-free for aggressive tree-shaking:

// package.json { "name": "my-library", "sideEffects": false, // No files have side effects // Or specify which files have side effects "sideEffects": [ "*.css", "*.scss", "./src/polyfills.ts" ] }

Write side-effect-free code:

// ❌ Has side effect (module-level execution) const API_KEY = process.env.API_KEY; console.log('Module loaded!'); export function makeRequest() { return fetch(`/api?key=${API_KEY}`); } // ✓ Side-effect-free export function makeRequest() { const API_KEY = process.env.API_KEY; return fetch(`/api?key=${API_KEY}`); }

Lazy Loading Types

Use dynamic imports for code splitting:

// Heavy library that's not always needed import type { Chart } from 'chart.js'; // Lazy load only when needed async function renderChart(data: number[]): Promise<void> { const { Chart } = await import('chart.js'); const chart = new Chart(/* ... */); // Use chart... } // Or with React.lazy const ChartComponent = React.lazy(() => import('./ChartComponent'));

Import Cost Analysis

Analyze what you're actually importing:

// ❌ Bad: Imports entire library (even if tree-shakeable) import _ from 'lodash'; const result = _.debounce(fn, 300); // ✓ Better: Named import (tree-shakeable) import { debounce } from 'lodash'; const result = debounce(fn, 300); // ✓ Best: Direct path import (smallest bundle) import debounce from 'lodash/debounce'; const result = debounce(fn, 300); // For modern lodash-es (tree-shakeable by default) import { debounce } from 'lodash-es';

Declaration Files and Performance

Optimize type declarations for publishing libraries:

{ "compilerOptions": { // Generate declarations "declaration": true, "declarationMap": true, // Faster type checking for consumers "skipLibCheck": true, // Single .d.ts file (faster resolution) "declarationDir": "./dist/types", // Strip internal types "stripInternal": true } }

Use @internal JSDoc comment to hide implementation details:

/** * Public API function */ export function publicApi(): void { internalHelper(); } /** * @internal * Internal implementation detail (stripped from declarations) */ export function internalHelper(): void { // Implementation }

Incremental Compilation

Speed up development builds with incremental compilation:

{ "compilerOptions": { "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", // For monorepos "composite": true } }

Build info file caches type information between builds:

# First build: slower tsc --build # Subsequent builds: much faster (only changed files) tsc --build # Clean build info tsc --build --clean

Module Resolution Performance

Optimize module resolution for faster compilation:

{ "compilerOptions": { // Faster resolution with bundler "moduleResolution": "bundler", // Skip lib checking (huge speedup) "skipLibCheck": true, // Specify type roots to reduce lookup "typeRoots": ["./node_modules/@types"], // Limit types to only what you need "types": ["node", "jest"], // Base URL for path mapping "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

Runtime Performance Patterns

Write TypeScript that compiles to performant JavaScript:

// ❌ Slow: Creates new object on every call function getConfig() { return { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3 }; } // ✓ Fast: Reuses same object const CONFIG = { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3 } as const; function getConfig() { return CONFIG; } // ❌ Slow: Type guard with complex checks function isUser(obj: unknown): obj is User { return ( typeof obj === 'object' && obj !== null && 'id' in obj && typeof obj.id === 'number' && 'name' in obj && typeof obj.name === 'string' && 'email' in obj && typeof obj.email === 'string' ); } // ✓ Fast: Structural check with early returns function isUser(obj: unknown): obj is User { if (typeof obj !== 'object' || obj === null) return false; const u = obj as User; return typeof u.id === 'number' && typeof u.name === 'string'; }

Webpack/Vite Optimization Tips

Configure your bundler for optimal TypeScript builds:

Vite Configuration:

// vite.config.ts import { defineConfig } from 'vite'; export default defineConfig({ build: { // Enable minification minify: 'esbuild', // Enable tree-shaking target: 'es2020', // Chunk splitting rollupOptions: { output: { manualChunks: { 'vendor': ['react', 'react-dom'], 'utils': ['./src/utils/index'] } } } }, // Use esbuild for faster transpilation esbuild: { target: 'es2020', drop: ['console', 'debugger'] // Remove in production } });

Webpack Configuration:

// webpack.config.js module.exports = { module: { rules: [ { test: /\.tsx?$/, use: 'esbuild-loader', // Much faster than ts-loader options: { loader: 'tsx', target: 'es2020' } } ] }, optimization: { usedExports: true, // Tree shaking sideEffects: true, // Respect sideEffects field splitChunks: { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 10 } } } } };

Measuring Bundle Size

Tools for analyzing bundle composition:

# Install bundle analyzer npm install --save-dev webpack-bundle-analyzer # Vite bundle analysis npm install --save-dev rollup-plugin-visualizer # Size limit checking npm install --save-dev size-limit @size-limit/preset-app
// package.json { "scripts": { "analyze": "vite build --mode analyze", "size": "size-limit" }, "size-limit": [ { "path": "dist/index.js", "limit": "50 KB" } ] }

Production Build Checklist

Optimization Checklist:
  • ✓ Use import type for type-only imports
  • ✓ Enable isolatedModules for faster transpilation
  • ✓ Prefer union types over enums when possible
  • ✓ Set skipLibCheck: true for faster builds
  • ✓ Use named imports from tree-shakeable libraries
  • ✓ Mark packages as side-effect-free in package.json
  • ✓ Enable removeComments in production
  • ✓ Use code splitting for large dependencies
  • ✓ Analyze bundle size regularly
  • ✓ Set up bundle size limits in CI

Real-World Example: Before and After

// ❌ Before optimization (poor bundle performance) import _ from 'lodash'; import moment from 'moment'; import * as utils from './utils'; enum ApiStatus { Loading = 0, Success = 1, Error = 2 } const config = { apiUrl: process.env.API_URL }; export class ApiService { constructor() { console.log('ApiService initialized'); } formatDate(date: Date): string { return moment(date).format('YYYY-MM-DD'); } debounceRequest = _.debounce(this.makeRequest, 300); makeRequest(data: any): void { // Implementation } } // ✓ After optimization import type { RequestData } from './types'; import debounce from 'lodash/debounce'; type ApiStatus = 'loading' | 'success' | 'error'; const getConfig = () => ({ apiUrl: process.env.API_URL }); export class ApiService { formatDate(date: Date): string { return date.toISOString().split('T')[0]; } debounceRequest = debounce(this.makeRequest.bind(this), 300); makeRequest(data: RequestData): void { // Implementation } } // Bundle savings: ~200 KB → ~15 KB
Exercise:
  1. Set up bundle analysis for your project using webpack-bundle-analyzer or rollup-plugin-visualizer
  2. Identify the 3 largest dependencies in your bundle
  3. Replace any default imports with named imports where possible
  4. Convert enums to union types and const objects
  5. Add import type for all type-only imports
  6. Measure bundle size before and after optimizations
  7. Set up size-limit to prevent bundle size regressions