← Back to DevBytes

ESBuild TypeScript: Strongly Typed Applications

ESBuild TypeScript: Strongly Typed Applications

ESBuild has rapidly gained traction as a lightning-fast JavaScript and TypeScript bundler. When paired with TypeScript's robust type system, it unlocks a workflow that is both blazingly fast and strongly typed. This tutorial walks you through everything you need to know to build production-ready, strongly typed applications using ESBuild and TypeScript, from initial setup to advanced best practices.

What Is ESBuild?

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

ESBuild is an extremely fast JavaScript bundler written in Go. It compiles TypeScript, JavaScript, and JSX files into browser-ready bundles at speeds that often exceed 100x faster than traditional bundlers like Webpack or Rollup. It supports ES modules, CommonJS, code splitting, CSS bundling, and a plugin architectureβ€”all while maintaining near-instant build times.

Key features of ESBuild include:

Why ESBuild Matters for TypeScript Projects

TypeScript brings type safety, better IDE tooling, and self-documenting codebases. However, traditional TypeScript compilation with tsc can be slow in large projects because it performs full type checking and emits JavaScript file-by-file. ESBuild takes a different approach: it strips type annotations only and bundles everything at incredible speed. This separation of concernsβ€”fast bundling with ESBuild plus separate type checkingβ€”gives you the best of both worlds: a strongly typed development experience with instant builds.

The real power emerges when you combine ESBuild's speed with TypeScript's type discipline. You get:

Setting Up a TypeScript Project with ESBuild

Prerequisites

You need Node.js (version 14 or later) and a package manager like npm or pnpm. Create a new project directory and initialize it:

mkdir my-ts-esbuild-app
cd my-ts-esbuild-app
npm init -y

Install Dependencies

Install ESBuild as a development dependency along with TypeScript for type definitions:

npm install --save-dev esbuild typescript
npm install --save-dev @types/node  # if targeting Node.js

For a browser application, you might also install:

npm install --save-dev @types/dom-web-api

Create a tsconfig.json

Even though ESBuild doesn't perform type checking, you still need a tsconfig.json for your IDE and for running tsc as a separate type-check step. Create this file in your project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

The isolatedModules option is crucial here. Since ESBuild compiles each file in isolation (like Babel or SWC), enabling this flag ensures your TypeScript code is compatible with single-file transpilers. It will flag features like const enums, namespace merging, and non-imported type references that ESBuild cannot handle.

Project Structure for Strongly Typed Applications

A well-organized TypeScript project using ESBuild typically follows this structure:

my-ts-esbuild-app/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ index.ts          # Entry point
β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”œβ”€β”€ index.ts      # Shared type definitions
β”‚   β”‚   └── api.ts        # API response types
β”‚   β”œβ”€β”€ utils/
β”‚   β”‚   β”œβ”€β”€ logger.ts     # Utility with strong types
β”‚   β”‚   └── math.ts
β”‚   └── modules/
β”‚       β”œβ”€β”€ user.ts       # Domain logic
β”‚       └── product.ts
β”œβ”€β”€ dist/                 # Build output (gitignored)
β”œβ”€β”€ esbuild.config.mjs    # Build configuration
β”œβ”€β”€ tsconfig.json         # TypeScript configuration
└── package.json

Writing Strongly Typed Application Code

Let's create a complete example application that demonstrates ESBuild with rigorous TypeScript typing. We'll build a small e-commerce utility with strict type safety.

Define Domain Types

Create src/types/index.ts:

// src/types/index.ts

export interface User {
  readonly id: string;
  readonly email: string;
  readonly name: string;
  readonly role: UserRole;
  readonly createdAt: Date;
}

export enum UserRole {
  Admin = 'admin',
  Customer = 'customer',
  Guest = 'guest',
}

export interface Product {
  readonly sku: string;
  readonly title: string;
  readonly description: string;
  readonly price: Price;
  readonly inventory: Inventory;
  readonly categories: readonly string[];
}

export interface Price {
  readonly amount: number;
  readonly currency: Currency;
}

export enum Currency {
  USD = 'USD',
  EUR = 'EUR',
  GBP = 'GBP',
}

export interface Inventory {
  readonly quantity: number;
  readonly warehouse: string;
  readonly lastUpdated: Date;
}

export interface CartItem {
  readonly product: Product;
  readonly quantity: number;
}

export interface Order {
  readonly id: string;
  readonly user: User;
  readonly items: readonly CartItem[];
  readonly total: Price;
  readonly status: OrderStatus;
}

export enum OrderStatus {
  Pending = 'pending',
  Confirmed = 'confirmed',
  Shipped = 'shipped',
  Delivered = 'delivered',
  Cancelled = 'cancelled',
}

// Discriminated union for API responses
export type ApiResponse =
  | { readonly kind: 'success'; readonly data: T }
  | { readonly kind: 'error'; readonly code: number; readonly message: string };

// Utility types
export type DeepReadonly = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly : T[P];
};

export interface PaginatedResult {
  readonly items: readonly T[];
  readonly total: number;
  readonly page: number;
  readonly pageSize: number;
}

Implement Domain Logic with Strong Types

Create src/modules/user.ts:

// src/modules/user.ts
import { User, UserRole, ApiResponse } from '../types/index.js';

// Type-safe factory function
export function createUser(
  email: string,
  name: string,
  role: UserRole = UserRole.Customer
): User {
  return {
    id: generateId(),
    email: validateEmail(email),
    name,
    role,
    createdAt: new Date(),
  };
}

// Type guard
export function isAdmin(user: User): user is User & { role: UserRole.Admin } {
  return user.role === UserRole.Admin;
}

// Generic function with constraints
export function fetchUser>(
  identifier: T
): Promise> {
  return fetch(`/api/users/${identifier.id}`)
    .then((res) => res.json())
    .then((data: unknown) => {
      // Runtime validation with type assertion
      const user = data as User;
      if (!user.id || !user.email) {
        return {
          kind: 'error' as const,
          code: 400,
          message: 'Invalid user data',
        };
      }
      return { kind: 'success' as const, data: user };
    });
}

// Private helper functions
function generateId(): string {
  return crypto.randomUUID();
}

function validateEmail(email: string): string {
  if (!email.includes('@')) {
    throw new Error(`Invalid email: ${email}`);
  }
  return email.toLowerCase();
}

Create src/modules/product.ts:

// src/modules/product.ts
import {
  Product,
  Price,
  Currency,
  Inventory,
  CartItem,
  Order,
  OrderStatus,
  User,
  DeepReadonly,
} from '../types/index.js';

// Function returning a strongly typed partial
export function createProductTemplate(
  title: string,
  basePrice: number
): Omit {
  return {
    title,
    description: '',
    price: {
      amount: basePrice,
      currency: Currency.USD,
    },
    inventory: {
      quantity: 0,
      warehouse: 'default',
      lastUpdated: new Date(),
    },
    categories: [],
  };
}

// Function using readonly types for immutability guarantees
export function calculateOrderTotal(
  items: DeepReadonly
): Price {
  const totalAmount = items.reduce(
    (sum, item) => sum + item.product.price.amount * item.quantity,
    0
  );
  return {
    amount: Math.round(totalAmount * 100) / 100,
    currency: items[0]?.product.price.currency ?? Currency.USD,
  };
}

// Function with overloads for different parameter shapes
export function createOrder(
  user: User,
  items: CartItem[]
): Order;
export function createOrder(
  user: User,
  items: CartItem[],
  status: OrderStatus
): Order;
export function createOrder(
  user: User,
  items: CartItem[],
  status: OrderStatus = OrderStatus.Pending
): Order {
  const total = calculateOrderTotal(items);
  return {
    id: crypto.randomUUID(),
    user,
    items,
    total,
    status,
  };
}

// Const assertion for literal types
export const FREE_SHIPPING_THRESHOLD = {
  amount: 50,
  currency: Currency.USD,
} as const;

// Template literal types example
export type SKUPattern = `${string}-${number}-${string}`;

export function generateSKU(
  category: string,
  sequence: number,
  suffix: string
): SKUPattern {
  return `${category}-${sequence}-${suffix}`;
}

Create the Entry Point

Create src/index.ts:

// src/index.ts
import { createUser, fetchUser, isAdmin } from './modules/user.js';
import { createOrder, generateSKU } from './modules/product.js';
import {
  UserRole,
  Currency,
  type OrderStatus,
} from './types/index.js';

// Demonstrating type-safe usage
async function main(): Promise {
  const user = createUser('alice@example.com', 'Alice', UserRole.Admin);

  if (isAdmin(user)) {
    console.log(`${user.name} has admin privileges`);
  }

  const sku = generateSKU('electronics', 42, 'abc');
  console.log(`Generated SKU: ${sku}`);

  const result = await fetchUser({ id: user.id });
  if (result.kind === 'success') {
    console.log(`Fetched user: ${result.data.email}`);
  } else {
    console.error(`Error: ${result.message}`);
  }

  // Creating a strongly typed order
  const sampleProduct = {
    sku: 'widget-001-xyz',
    title: 'Premium Widget',
    description: 'A very nice widget',
    price: { amount: 29.99, currency: Currency.USD },
    inventory: {
      quantity: 100,
      warehouse: 'us-east',
      lastUpdated: new Date(),
    },
    categories: ['widgets', 'premium'],
  };

  const order = createOrder(user, [
    { product: sampleProduct, quantity: 2 },
  ]);

  console.log(`Order ${order.id} created with status: ${order.status}`);
  console.log(`Total: ${order.total.currency} ${order.total.amount}`);
}

main().catch(console.error);

Configuring the ESBuild Build

ESBuild can be configured via CLI flags, but for complex setups, a JavaScript configuration file gives you the most flexibility. Create esbuild.config.mjs:

// esbuild.config.mjs
import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';

const isWatchMode = process.argv.includes('--watch');
const isProduction = process.argv.includes('--production');

/** @type {import('esbuild').BuildOptions} */
const baseConfig = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node',          // 'browser' for web apps
  target: 'es2022',
  format: 'esm',             // ES modules output
  outfile: 'dist/bundle.mjs',
  sourcemap: !isProduction,
  minify: isProduction,
  treeShaking: true,
  metafile: true,            // Generate metadata for analysis
  logLevel: 'info',
  // Handle TypeScript path aliases manually or via plugin
  alias: {
    '@': './src',
  },
  // External packages that shouldn't be bundled
  external: [
    'node:fs',
    'node:path',
    'node:crypto',
  ],
};

async function build() {
  try {
    const context = await esbuild.context(baseConfig);

    if (isWatchMode) {
      console.log('πŸ‘€ Watching for changes...');
      await context.watch();
    } else {
      const result = await context.rebuild();

      // Log bundle analysis
      const analysis = await analyzeMetafile(result.metafile);
      console.log(analysis);

      await context.dispose();
    }
  } catch (error) {
    console.error('Build failed:', error);
    process.exit(1);
  }
}

async function analyzeMetafile(metafile) {
  const text = await esbuild.analyzeMetafile(metafile, { verbose: true });
  return text;
}

build();

Adding Type Checking to the Pipeline

Since ESBuild only strips types, you must run tsc separately to verify type correctness. Here are three recommended approaches:

Approach 1: Dual Script Pattern (Recommended)

Add these scripts to your package.json:

{
  "scripts": {
    "build": "npm run typecheck && npm run bundle",
    "typecheck": "tsc --noEmit",
    "bundle": "node esbuild.config.mjs",
    "bundle:prod": "node esbuild.config.mjs --production",
    "watch": "node esbuild.config.mjs --watch",
    "watch:types": "tsc --noEmit --watch",
    "dev": "concurrently \"npm run watch\" \"npm run watch:types\"",
    "clean": "rm -rf dist"
  }
}

The --noEmit flag tells tsc to check types without producing any JavaScript filesβ€”ESBuild handles the actual compilation. Install concurrently for running both watch processes:

npm install --save-dev concurrently

Approach 2: CI Pipeline Integration

For continuous integration, separate type checking and bundling into distinct steps:

# .github/workflows/build.yml (example snippet)
- name: Type check
  run: npx tsc --noEmit --strict
- name: Bundle with ESBuild
  run: node esbuild.config.mjs --production

Approach 3: ESBuild Plugin for Type Checking

You can create a custom ESBuild plugin that runs tsc before bundling. Create esbuild-plugin-typecheck.mjs:

// esbuild-plugin-typecheck.mjs
import { execSync } from 'node:child_process';

/** @returns {import('esbuild').Plugin} */
export function typeCheckPlugin() {
  return {
    name: 'typecheck',
    setup(build) {
      build.onStart(async () => {
        try {
          console.log('πŸ” Running type check...');
          execSync('npx tsc --noEmit', {
            stdio: 'inherit',
            cwd: process.cwd(),
          });
          console.log('βœ… Type check passed');
        } catch (error) {
          console.error('❌ Type check failed');
          // Abort the build on type errors
          throw new Error('TypeScript type checking failed. Fix errors and try again.');
        }
      });
    },
  };
}

Then use it in your build configuration:

// In esbuild.config.mjs
import { typeCheckPlugin } from './esbuild-plugin-typecheck.mjs';

const baseConfig = {
  // ... other options
  plugins: [typeCheckPlugin()],
};

Handling Path Aliases in ESBuild

TypeScript path aliases (like @/*) are resolved by tsc but not automatically by ESBuild. You have two options:

Option 1: ESBuild Alias Configuration

// esbuild.config.mjs
const baseConfig = {
  alias: {
    '@': './src',
    '@components': './src/components',
    '@utils': './src/utils',
  },
};

Option 2: ESBuild Plugin for tsconfig Paths

Install and use a plugin that reads path aliases from tsconfig.json:

npm install --save-dev @esbuild-plugins/tsconfig-paths
// esbuild.config.mjs
import { tsconfigPathsPlugin } from '@esbuild-plugins/tsconfig-paths';

const baseConfig = {
  plugins: [tsconfigPathsPlugin()],
};

Production Build Optimizations

For production deployments, ESBuild offers several optimization options that work seamlessly with TypeScript code:

// esbuild.config.mjs - Production-specific configuration
/** @type {import('esbuild').BuildOptions} */
const productionConfig = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  minify: true,               // Enable minification
  minifySyntax: true,         // Minify syntax only
  minifyWhitespace: true,     // Remove whitespace
  minifyIdentifiers: true,    // Shorten variable names
  target: ['es2020', 'node18'],
  platform: 'node',
  format: 'esm',
  outfile: 'dist/bundle.min.mjs',
  sourcemap: false,
  treeShaking: true,
  metafile: true,
  legalComments: 'none',      // Strip legal comments
  drop: ['debugger', 'console'], // Remove debugger and console statements
  // Drop specific TypeScript-only constructs
  define: {
    'process.env.NODE_ENV': '"production"',
  },
};

async function productionBuild() {
  const result = await esbuild.build(productionConfig);
  const analysis = await esbuild.analyzeMetafile(result.metafile);
  console.log('Bundle analysis:', analysis);

  // Log bundle size
  const bytes = result.outputFiles?.[0]?.contents.length ?? 0;
  console.log(`Bundle size: ${(bytes / 1024).toFixed(2)} KB`);
}

Code Splitting with TypeScript and ESBuild

ESBuild supports dynamic import splitting. Here's how to structure your TypeScript code for optimal splitting:

// src/index.ts - Entry point with dynamic imports
async function loadModule(moduleName: string): Promise {
  // TypeScript understands dynamic imports
  type ModuleType = {
    default: (input: string) => string;
    version: string;
  };

  const module = await import(`./modules/${moduleName}.ts`) as ModuleType;
  console.log(`Loaded ${moduleName} v${module.version}`);
  return module.default('hello');
}

// ESBuild will automatically split this into separate chunks
async function main() {
  await loadModule('user');
  await loadModule('product');
}

main().catch(console.error);

Configure ESBuild for code splitting using the splitting option (requires ESM format):

// esbuild.config.mjs - Splitting configuration
const splittingConfig = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  splitting: true,           // Enable code splitting
  format: 'esm',
  outdir: 'dist/chunks',     // Must use outdir for splitting
  chunkNames: 'chunks/[name]-[hash]',
  treeShaking: true,
};

Building Declaration Files

ESBuild cannot generate .d.ts declaration files. For libraries that need to distribute types, run tsc with declaration enabled:

// package.json scripts for library builds
{
  "scripts": {
    "build:lib": "npm run build:types && npm run build:bundle",
    "build:types": "tsc --declaration --declarationMap --emitDeclarationOnly --outDir dist/types",
    "build:bundle": "node esbuild.config.mjs --production"
  }
}

Then configure your package.json to point to both the bundle and types:

{
  "main": "./dist/bundle.min.mjs",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/bundle.min.mjs",
      "types": "./dist/types/index.d.ts"
    }
  }
}

Best Practices for ESBuild TypeScript Projects

1. Always Enable isolatedModules

Set "isolatedModules": true in tsconfig.json. This guarantees your TypeScript code is compatible with ESBuild's single-file transpilation model. It prevents patterns that require full-program type information:

// ❌ Will fail with isolatedModules β€” const enum requires global knowledge
export const enum Status { Active, Inactive }

// βœ… Use regular enum or string literals instead
export enum Status { Active = 'active', Inactive = 'inactive' }
// Or better yet:
export const Status = {
  Active: 'active',
  Inactive: 'inactive',
} as const;
export type Status = (typeof Status)[keyof typeof Status];

2. Separate Type Imports Explicitly

Use import type for type-only imports. This helps ESBuild optimize the bundle by completely removing type imports at build time:

// βœ… Clear separation
import type { User, Product } from './types/index.js';
import { createUser } from './modules/user.js';

// ❌ Mixed imports β€” ESBuild handles this but explicit is clearer
import { type User, createUser } from './modules/user.js';

3. Use ESBuild's Define for Environment Variables

Replace environment-specific values at build time:

// esbuild.config.mjs
define: {
  'process.env.API_URL': JSON.stringify(process.env.API_URL ?? 'http://localhost:3000'),
  'process.env.VERSION': JSON.stringify('1.0.0'),
},

4. Implement a Robust Type Checking CI Step

Never skip type checking in CI. A common anti-pattern is relying solely on ESBuild for everything. Your CI pipeline should:

# Fails fast on type errors before bundling
npx tsc --noEmit --strict && node esbuild.config.mjs --production

5. Leverage ESBuild's Content-Based API

For serving TypeScript files in development, use ESBuild's transform API directly:

// dev-server.mjs
import * as esbuild from 'esbuild';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const server = createServer(async (req, res) => {
  if (req.url?.endsWith('.ts')) {
    const source = await readFile('.' + req.url, 'utf-8');
    const result = await esbuild.transform(source, {
      loader: 'ts',
      sourcemap: 'inline',
      target: 'es2022',
    });
    res.setHeader('Content-Type', 'application/javascript');
    res.end(result.code);
  }
});

server.listen(3000);

6. Keep tsconfig Strict

Enable all strict checks for maximum type safety:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true
  }
}

7. Use ESBuild Plugins Wisely

Plugins run in JavaScript and can slow down builds if not optimized. Keep plugin logic minimal. For type checking, consider running it outside ESBuild's plugin system to maintain maximum bundling speed.

Common Pitfalls and How to Avoid Them

Pitfall 1: Assuming ESBuild does full TypeScript compilation. Solution: Always run tsc --noEmit as a separate step.

Pitfall 2: Using TypeScript features incompatible with isolated modules. Solution: Enable isolatedModules and let your IDE warn you early.

Pitfall 3: Forgetting to configure path aliases in ESBuild. Solution: Use the alias config or a tsconfig paths plugin.

Pitfall 4: Expecting declaration file generation. Solution: Use tsc --emitDeclarationOnly for .d.ts files.

Pitfall 5: Bundling everything including Node.js built-ins. Solution: Mark platform-specific modules as external.

Conclusion

ESBuild and TypeScript form a powerful combination for building strongly typed applications with unprecedented speed. By letting ESBuild handle the heavy lifting of bundling and minification while TypeScript provides rigorous type checking through a separate pipeline, you achieve both velocity and safety. The key insight is embracing the separation of concerns: ESBuild strips types and bundles fast; TypeScript checks types thoroughly. With proper configuration, path alias handling, isolated modules, and a CI-friendly dual-script pattern, your TypeScript projects will build in milliseconds while maintaining the full integrity of a strongly typed codebase. This workflow scales beautifully from small libraries to large enterprise applications, giving you the confidence of type safety without ever waiting on slow builds.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles