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:
- Set up bundle analysis for your project using webpack-bundle-analyzer or rollup-plugin-visualizer
- Identify the 3 largest dependencies in your bundle
- Replace any default imports with named imports where possible
- Convert enums to union types and const objects
- Add
import type for all type-only imports
- Measure bundle size before and after optimizations
- Set up size-limit to prevent bundle size regressions