esbuild can bundle JavaScript and TypeScript from scratch, and it’s ridiculously fast doing it.

Let’s say you’ve got a simple project:

.
├── src/
│   ├── index.ts
│   └── utils.ts
└── package.json

src/utils.ts:

export function greet(name: string): string {
  return `Hello, ${name}!`;
}

src/index.ts:

import { greet } from './utils';

const message = greet('World');
console.log(message);

package.json:

{
  "name": "esbuild-scratch",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "build": "node build.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "esbuild": "^0.20.0",
    "typescript": "^5.0.0"
  }
}

Now, you want to bundle this into a single JavaScript file, dist/index.js, using esbuild.

First, install the dependencies:

npm install

Next, create a build.js file in your project root:

const esbuild = require('esbuild');
const fs = require('fs');

const buildDir = 'dist';

// Ensure the build directory exists
if (!fs.existsSync(buildDir)) {
  fs.mkdirSync(buildDir);
}

esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/index.js',
  format: 'cjs', // CommonJS module format for Node.js
  platform: 'node', // Target platform is Node.js
  sourcemap: true,
  splitting: false, // Keep as a single file
  minify: false,
  target: 'es2020', // Specify the ECMAScript version to target
}).then(() => {
  console.log('Build successful!');
}).catch((error) => {
  console.error('Build failed:', error);
  process.exit(1);
});

Run the build script:

npm run build

This will create dist/index.js and dist/index.js.map.

The dist/index.js file will look something like this:

var __defProp = Object.defineProperty;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsBuffer = Object.prototype.propertyIsEnumerable;
var __defNormalEsm = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value: value }) : obj[key] = value;
var __spreadValues = (a, b) => {
  for (var prop in b || (b = {}))
    if (__hasOwnProp.call(b, prop))
      __defNormalEsm(a, prop, b[prop]);
  if (__getOwnPropSymbols)
    for (var prop of __getOwnPropSymbols(b))
      if (__propIsBuffer.call(b, prop))
        __defNormalEsm(a, prop, b[prop]);
  return a;
};
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, mod = { exports: {} }, cb(mod.exports, mod)), mod.exports;
};
var require_utils = __commonJS((exports2) => {
  "use strict";
  __defProp(exports2, "__esModule", { value: true });
  __defNormalEsm(exports2, "greet", (name) => {
    return `Hello, ${name}!`;
  });
});
var index_js_exports = {};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (const key of __getOwnPropSymbols(from)) {
      if (!except.includes(key) && __propIsBuffer.call(from, key)) {
        __defProp(to, key, desc === void 0 ? __getOwnPropDesc(from, key) : desc);
      }
    }
  }
  return to;
};
var __toESM = (mod, isNodeModules) => {
  return __defProp(currentScope.exports, "default", { enumerable: true, value: mod });
};
var __toCommonJS = (mod) => __quickjs_require_to_commonjs_module(mod);
var entry_point = __toCommonJS({
  "src/index.ts": (() => {
    var __defProp = Object.defineProperty;
    var __getOwnPropSymbols = Object.getOwnPropertySymbols;
    var __hasOwnProp = Object.prototype.hasOwnProperty;
    var __propIsBuffer = Object.prototype.propertyIsEnumerable;
    var __defNormalEsm = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value: value }) : obj[key] = value;
    var __spreadValues = (a, b) => {
      for (var prop in b || (b = {}))
        if (__hasOwnProp.call(b, prop))
          __defNormalEsm(a, prop, b[prop]);
      if (__getOwnPropSymbols)
        for (var prop of __getOwnPropSymbols(b))
          if (__propIsBuffer.call(b, prop))
            __defNormalEsm(a, prop, b[prop]);
      return a;
    };
    var __commonJS = (cb, mod) => function __require() {
      return mod || (0, mod = { exports: {} }, cb(mod.exports, mod)), mod.exports;
    };
    var require_utils = __commonJS((exports2) => {
      "use strict";
      __defProp(exports2, "__esModule", { value: true });
      __defNormalEsm(exports2, "greet", (name) => {
        return `Hello, ${name}!`;
      });
    });
    var require_index = __toCommonJS({
      "src/index.ts": (() => {
        "use strict";
        var utils_exports = require_utils();
        const message = utils_exports.greet("World");
        console.log(message);
      })
    });
    return require_index;
  })()
});

Let’s break down the esbuild.build options:

  • entryPoints: An array of files that are the starting points for your application. esbuild will trace imports from these files.
  • bundle: true: This is the core option that tells esbuild to combine all your modules into a single file.
  • outfile: The path where the bundled output should be written.
  • format: Specifies the module system of the output code. cjs for CommonJS (Node.js), esm for ECMAScript Modules (browsers, modern Node.js).
  • platform: The target environment for the output. node for Node.js, browser for web browsers.
  • sourcemap: true: Generates a source map file (.map) which allows you to debug the original TypeScript/JavaScript code in your browser’s developer tools or Node.js debugger.
  • splitting: false: When true and format is esm, esbuild can create multiple output files for code splitting. We’re keeping it false to get a single bundle.
  • minify: false: If true, esbuild will remove whitespace, shorten variable names, and perform other optimizations to reduce file size.
  • target: The ECMAScript version to target. esbuild will transpile down to this version if necessary. es2020 is a good modern target for Node.js.

The internal structure of the bundled file is interesting. esbuild uses a form of __commonJS wrapper to make modules work in a single file, essentially simulating require for each module it encounters and then executing them. It also generates helper functions like __defProp, __getOwnPropSymbols, and __spreadValues for handling JavaScript features and object manipulation, especially when dealing with ES Modules and TypeScript’s type stripping.

The __toCommonJS and __toESM helpers are crucial for managing how modules are exposed and imported within the bundled output, ensuring that even though it’s one file, the module system is respected.

If you want to build for the browser instead, you’d change format to esm (or iife for an immediately invoked function expression) and platform to browser. You might also want to enable minify: true for production builds.

const esbuild = require('esbuild');
const fs = require('fs');

const buildDir = 'dist';

if (!fs.existsSync(buildDir)) {
  fs.mkdirSync(buildDir);
}

esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/browser.js', // Different output file name
  format: 'esm', // ECMAScript Module format for browsers
  platform: 'browser', // Target platform is the browser
  sourcemap: true,
  splitting: true, // Enable code splitting for browser
  minify: true, // Minify for smaller browser bundle
  target: 'es2020', // Target modern browsers
}).then(() => {
  console.log('Browser build successful!');
}).catch((error) => {
  console.error('Browser build failed:', error);
  process.exit(1);
});

Running this second build script would produce dist/browser.js and dist/browser.js.map.

When esbuild bundles, it doesn’t just concatenate files; it performs a full static analysis of your import graph. This allows it to tree-shake unused code (if minify: true is set and your code allows for it) and resolve dependencies efficiently. The target option ensures that modern JavaScript features are either kept as-is or transpiled down to a compatible version, providing control over browser compatibility.

The most surprising thing is how esbuild achieves its speed: it’s written in Go and uses a highly optimized parallel processing model. When it encounters an import, it doesn’t just read the file; it parses it into an Abstract Syntax Tree (AST), analyzes it, and then transforms it. This AST manipulation is done in memory, and Go’s concurrency primitives allow esbuild to process many files simultaneously across multiple CPU cores. The output is then generated by serializing these ASTs back into code. This avoids the overhead of many JavaScript-based bundlers that might rely on external processes or less efficient inter-process communication.

Once you’ve got esbuild working, the next logical step is to integrate it into a development workflow with a dev server that uses esbuild’s serve API for hot module replacement.

Want structured learning?

Take the full Esbuild course →