When a TypeScript library project grows past a few thousand lines, tsc transpilation starts to dominate CI run time. Switching to esbuild for the emit step while keeping tsc for type checking is the standard way to reclaim those minutes — but the split architecture introduces several sharp edges: missing declaration files, silently ignored compiler flags, and a package.json exports field that no longer maps to the right output paths. This guide walks through every step of that migration and how to avoid each failure mode.


Why esbuild Does Not Replace tsc Completely

esbuild is a transpiler, not a type checker. It strips TypeScript syntax and emits JavaScript by treating type annotations as comments to delete — it does not build a type graph, resolve generics, or enforce strictNullChecks. Most compilerOptions in your tsconfig.json are silently ignored by esbuild; passing TypeScript-specific flags to the esbuild CLI produces no error, but they have no effect.

The confusion arises because the error that makes teams reach for esbuild in the first place often comes from tsc, not esbuild:

error TS5023: Unknown compiler option 'verbatimModuleSyntax'

That error appears when running tsc against a config that the installed TypeScript version does not yet support — not when running esbuild. The correct migration keeps tsc for type checking and hands transpilation exclusively to esbuild. The modern build tools comparison covers when esbuild, tsup, and Rollup each make sense.


How the Pipeline Splits

The diagram below shows how a single tsc command becomes two parallel branches after migration.

tsc to esbuild migration pipeline Before migration: one tsc command handles both type checking and emit. After migration: esbuild handles transpile and dual-format emit in parallel with tsc --noEmit for type checking, then tsc --emitDeclarationOnly for .d.ts files. Before src/index.ts tsc (type-check + emit) dist/index.js + dist/index.d.ts After src/index.ts esbuild transpile only (parallel) index.mjs (ESM) index.cjs (CJS) tsc --noEmit type-check + .d.ts emit dist/types/index.d.ts

Minimal Reproduction of the Broken State

A project that relies on tsc for everything works at first. The breakage appears when you remove tsc from the emit path without supplying an equivalent for declaration generation and type validation.

Minimal package.json that breaks after naively replacing tsc with esbuild:

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "esbuild src/index.ts --bundle --format=esm --outdir=dist"
  }
}

Running npm run build produces dist/index.js (no .mjs, no .d.ts) and no type errors surface even if the source contains them. Consumers who import the package in a TypeScript project get:

Could not find a declaration file for module 'my-lib'.

Minimal tsconfig.json that makes tsc fail with an unknown option:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true,
    "module": "NodeNext",
    "target": "ES2022",
    "outDir": "dist"
  }
}

Running tsc with TypeScript 4.x produces error TS5023: Unknown compiler option 'verbatimModuleSyntax'. The solution is not to remove the option — it is to upgrade TypeScript to 5.0+ or separate the tsconfig.json used for type checking from one used for older tool compatibility.


Step-by-Step Fix

Step 1 — Create a type-check-only tsconfig

Add a tsconfig.build.json that inherits from your base config but disables emit. This is the config tsc will use; esbuild never reads it.

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
    "skipLibCheck": true,
    "declarationDir": "dist/types",
    "declaration": true,
    "emitDeclarationOnly": false
  }
}

Keep noEmit: true here. Declaration files come from a separate invocation in step 3.

Step 2 — Run parallel ESM and CJS builds with esbuild

esbuild cannot emit two formats in one invocation because the output syntax differs. Chain two commands:

npx esbuild src/index.ts \
  --bundle \
  --outdir=dist/esm \
  --format=esm \
  --out-extension:.js=.mjs \
  --platform=node \
  --sourcemap \
&& npx esbuild src/index.ts \
  --bundle \
  --outdir=dist/cjs \
  --format=cjs \
  --out-extension:.js=.cjs \
  --platform=node \
  --sourcemap

--out-extension:.js=.mjs renames outputs so Node.js treats them as ESM regardless of the nearest package.json type field. --platform=node keeps built-in specifiers (fs, path, stream) as externals and prevents polyfill injection.

Before (single tsc emit):

- "build": "tsc --project tsconfig.json"

After (esbuild dual-format):

+ "build:esm": "esbuild src/index.ts --bundle --outdir=dist/esm --format=esm --out-extension:.js=.mjs --platform=node --sourcemap",
+ "build:cjs": "esbuild src/index.ts --bundle --outdir=dist/cjs --format=cjs --out-extension:.js=.cjs --platform=node --sourcemap",
+ "build": "npm run build:esm && npm run build:cjs"

Step 3 — Generate declaration files with tsc --emitDeclarationOnly

esbuild emits zero .d.ts files. Run tsc in a dedicated declaration-only mode:

npx tsc \
  --project tsconfig.build.json \
  --noEmit false \
  --emitDeclarationOnly \
  --declarationDir dist/types

Add this to package.json:

+ "build:types": "tsc --project tsconfig.build.json --noEmit false --emitDeclarationOnly --declarationDir dist/types",
  "build": "npm run build:esm && npm run build:cjs && npm run build:types"

Step 4 — Run type checking in parallel with the transpile step

Type-check in CI alongside the esbuild steps so errors surface without adding to the critical path:

# In CI (bash):
npx tsc --project tsconfig.build.json --noEmit &
npx esbuild src/index.ts --bundle --format=esm --outdir=dist/esm --out-extension:.js=.mjs --platform=node
wait $!   # fail the script if tsc exits non-zero

Or as a package.json script using concurrently:

{
  "build": "concurrently --kill-others-on-fail \"npm run typecheck\" \"npm run build:esm\" \"npm run build:cjs\"",
  "typecheck": "tsc --project tsconfig.build.json --noEmit",
  "build:esm": "esbuild src/index.ts --bundle --outdir=dist/esm --format=esm --out-extension:.js=.mjs --platform=node",
  "build:cjs": "esbuild src/index.ts --bundle --outdir=dist/cjs --format=cjs --out-extension:.js=.cjs --platform=node"
}

Step 5 — Wire the exports field in package.json

Map both outputs with explicit conditions. To avoid the dual-package hazard that arises when ESM and CJS copies of a module coexist in the same process, use named extensions (.mjs / .cjs) and point each condition to its own directory:

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/types/index.d.ts",
        "default": "./dist/esm/index.mjs"
      },
      "require": {
        "types": "./dist/types/index.d.ts",
        "default": "./dist/cjs/index.cjs"
      }
    }
  },
  "types": "./dist/types/index.d.ts"
}

The types condition inside each branch is required for TypeScript 5+ to resolve the correct declaration file per condition. Without it, TypeScript falls back to the root types field, which always resolves to one format’s declarations regardless of the consumer’s module mode.


Verification Command

Run these in order after a clean build to confirm the migration is correct:

# 1. Confirm outputs exist
ls dist/esm/index.mjs dist/cjs/index.cjs dist/types/index.d.ts

# 2. Validate package.json exports and types fields
npx publint

# 3. Check that TypeScript resolves types correctly in both CJS and ESM conditions
npx attw --pack .

# 4. Confirm no type errors were silently swallowed by esbuild
npx tsc --project tsconfig.build.json --noEmit

Expected output from publint on a correctly migrated package:

 No issues found

Expected output from attw:

┌─────────────────────────────────────┐
│  Are the types wrong?               │
│  "." — types: ✓  esm: ✓  cjs: ✓   │
└─────────────────────────────────────┘

HAZARD PREVENTION: If attw reports "types": ✗ for the require condition, the types key is missing from the require block in your exports map. Add it pointing to the same dist/types/index.d.ts file — both conditions may share one declaration directory.


Edge Cases and Gotchas

  • tsconfig.json paths aliases are silently ignored by esbuild. Pass aliases with --alias:@utils=./src/utils on the CLI, or use the alias key in the esbuild JS API. Without this, monorepo workspace packages that use path aliases will produce MODULE_NOT_FOUND at runtime even though the build succeeds.

  • pnpm workspace packages need --external to prevent duplication. When bundling in a pnpm monorepo, mark shared workspace packages external (--external:@myorg/*) or consumers get two copies of the same module — one bundled, one resolved by the package manager.

  • TypeScript strict mode errors are invisible to esbuild. If you comment out the typecheck step in CI, code that passes tsc under strict: false but fails under strict: true will ship silently. Always keep the parallel tsc --noEmit step even after the migration feels stable.

  • verbatimModuleSyntax requires TypeScript 5.0+ and affects tsc only. If you see error TS5023: Unknown compiler option 'verbatimModuleSyntax', the fix is to upgrade TypeScript (npm i -D typescript@latest), not to switch away from esbuild. esbuild neither reads nor enforces this option.

  • esbuild’s --bundle flag inlines all local imports. If you want consumers to be able to tree-shake individual exports, either omit --bundle (so each file becomes a separate output) or use --splitting with --format=esm to generate a chunk graph instead of a single file.

  • Vite and Webpack consumers may pick different conditionNames. Vite defaults to ["browser", "module", "import", "default"], which means it will never match a bare "require" condition. Test with node --conditions=browser --input-type=module to simulate browser bundler resolution, and verify the exports field covers every condition your consumers need.

  • Source maps require --sourcemap on every esbuild invocation. Unlike tsc, which inherits sourceMap: true from tsconfig.json, esbuild does not read that option. Omitting --sourcemap in one of the two format invocations produces asymmetric debugging experience across ESM and CJS consumers.


FAQ

Does esbuild perform TypeScript type checking?

No. esbuild strips TypeScript syntax without validating types. You must run tsc --noEmit separately to catch type errors. Relying on esbuild alone will allow type-unsound code to ship silently.

Why does my .d.ts output disappear after switching to esbuild?

esbuild emits no declaration files. Run tsc --emitDeclarationOnly --declarationDir dist/types in a separate build step to generate them. The --noEmit false override is required if your tsconfig.build.json sets noEmit: true.

Can I use tsconfig paths aliases with esbuild?

esbuild does not read tsconfig.json paths. Pass aliases explicitly via --alias:@internal=./src/internal on the CLI or the alias option in the esbuild JS API. For more on alias handling in published packages, see path mapping and module resolution strategies.

My build passes but consumers get MODULE_NOT_FOUND at runtime — what is wrong?

The exports field in package.json likely does not point to the correct esbuild output paths, or the types condition is missing so TypeScript resolves the wrong declarations. Run npx attw --pack . to identify which conditions are broken.

How much faster is esbuild than tsc for transpilation?

esbuild is typically 10–100x faster than tsc for transpilation because it is written in Go and processes files in parallel. A project that takes 30 seconds with tsc often finishes in under one second with esbuild. Type checking still takes the same time — the speedup comes entirely from removing the type-checker from the emit critical path.



Back to Modern Build Tools: tsup, Rollup, and esbuild