Without a purpose-built tsconfig.json layout, TypeScript library builds fail in predictable ways: path aliases leak into published .d.ts files and cause MODULE_NOT_FOUND in consumers, a single outDir mixes CJS and ESM artifacts triggering ERR_REQUIRE_ESM at runtime, and type generation blocks JavaScript transpilation so CI takes twice as long. These failures surface in Node.js 18+ where native ESM enforcement became strict, and compound when bundlers like Webpack 5 or Vite apply their own moduleResolution assumptions against your emitted declarations.

A library’s tsconfig.json must serve two different masters: local development (fast feedback, path aliases, lenient checks) and published artifacts (strict resolution, no leaked aliases, environment-appropriate module settings). The solution is a shared base config with format-specific overrides and a separate types-only emit pass.

Prerequisites

Build Architecture Diagram

tsconfig.json split build architecture for dual ESM/CJS output Diagram showing tsconfig.base.json extended by tsconfig.cjs.json, tsconfig.esm.json, and tsconfig.types.json, each emitting to separate output directories fed into the package exports map. tsconfig.base.json strict · target · verbatimModuleSyntax tsconfig.cjs.json module: CommonJS → dist/cjs tsconfig.esm.json module: ESNext → dist/esm tsconfig.types.json emitDeclarationOnly → dist/types dist/cjs/*.js + .d.ts dist/esm/*.js + .d.ts dist/types/*.d.ts + .d.ts.map ↑ all three feed the package.json exports map

Canonical Configuration Block

Save this as tsconfig.base.json — the single source of truth for compiler strictness shared by every format build:

{
  "compilerOptions": {
    // Emit ES2022 syntax: top-level await, class fields, Object.hasOwn
    "target": "ES2022",

    // Full strict suite including strictNullChecks, noImplicitAny, strictFunctionTypes
    "strict": true,

    // Prevents import/export type erasure ambiguity; required for modern bundlers
    "verbatimModuleSyntax": true,

    // Bundler-friendly resolution: honours package.json exports without .js extensions
    "moduleResolution": "Bundler",

    // Allows parallel transpilation by esbuild/tsup without cross-file type info
    "isolatedModules": true,

    // Avoids false positives from @types/* in node_modules of consumers
    "skipLibCheck": true,

    // Prevents case-sensitivity bugs between macOS (case-insensitive) and Linux CI
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

Step-by-Step Implementation

Step 1 — Create format-specific override configs

tsconfig.base.json deliberately omits module and outDir. Each format config extends it and supplies those:

// tsconfig.cjs.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "./dist/cjs",
    "declaration": true,
    "declarationDir": "./dist/cjs/types"
  }
}
// tsconfig.esm.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./dist/esm",
    "declaration": true,
    "declarationDir": "./dist/esm/types"
  }
}

Node.js determines module format from the file extension and the type field in package.json. Mixing CJS and ESM output into the same outDir means a .js file can be loaded as the wrong format at runtime — with no compile-time error. Always emit to dist/cjs/ and dist/esm/ separately, then map both in the exports field.

Expected diff after running tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json:

dist/
  cjs/
    index.js
    types/
      index.d.ts
  esm/
    index.js
    types/
      index.d.ts

Step 2 — Isolate path aliases from distribution builds

Internal path aliases (baseUrl and paths) are convenient during development but fatal when published. Consumers lack your workspace configuration, so leaked aliases produce immediate MODULE_NOT_FOUND errors.

Keep aliases in a dev-only config that is never passed to tsc for distribution:

// tsconfig.json  (IDE / editor / dev server only — NOT used by build scripts)
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@internal/*": ["src/*"]
    }
  }
}

Then run tsc-alias after each format build to rewrite any surviving aliases to relative paths:

{
  "scripts": {
    "build:cjs": "tsc -p tsconfig.cjs.json && tsc-alias -p tsconfig.cjs.json",
    "build:esm": "tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json"
  }
}

Verify no aliases escaped into the output:

grep -r "@internal" dist/ && echo "ALIAS LEAK DETECTED" || echo "Clean — no leaked aliases"

For the full resolution picture see Path Mapping and Module Resolution Strategies.

Step 3 — Decouple type emission from JavaScript transpilation

emitDeclarationOnly: true generates .d.ts files without emitting JavaScript, so tsc and your JS bundler can run in parallel. This typically halves CI build time for medium-to-large libraries:

// tsconfig.types.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "emitDeclarationOnly": true,
    "declaration": true,
    "declarationMap": true,
    "declarationDir": "./dist/types",
    "rootDir": "./src"
  }
}

Parallel build script:

#!/usr/bin/env bash
set -euo pipefail

# Type declarations (tsc) and JavaScript bundles (esbuild) run concurrently
tsc -p tsconfig.types.json &
TSC_PID=$!

esbuild src/index.ts --bundle=false --format=esm --outdir=dist/esm --sourcemap &
ESM_PID=$!

esbuild src/index.ts --bundle=false --format=cjs --outdir=dist/cjs --sourcemap &
CJS_PID=$!

wait $TSC_PID $ESM_PID $CJS_PID
echo "Build complete."

HAZARD PREVENTION — missing declarationMap: Omitting declarationMap: true breaks “Go to Definition” across package boundaries in every IDE. Consumers clicking into your library see raw .d.ts content instead of your original TypeScript source. Always include it.

The full mechanics of declaration file output — including stripInternal, /** @internal */ JSDoc stripping, and .d.mts/.d.cts dual-extension patterns — are covered in Declaration File Generation and Type Stripping.

Step 4 — Validate types across runtime environments

Different runtime environments expose different global types. A single tsc --noEmit with browser lib settings will not catch process.env usage that breaks in browsers, nor DOM API calls that fail in Node.js. Run a matrix validation in CI:

# .github/workflows/type-check.yml
name: Cross-Environment Type Validation
on: [push, pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [node, browser]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Validate ${{ matrix.env }} types
        run: |
          if [ "${{ matrix.env }}" == "browser" ]; then
            npx tsc --noEmit --lib ES2022,DOM --strict --moduleResolution bundler
          else
            npx tsc --noEmit --lib ES2022 --strict --moduleResolution nodenext
          fi

Always enforce strict: true in every validation gate. Disabling it allows implicit any types to appear in published declarations, causing type errors for consumers who do enable strict mode.

Step 5 — Wire the output into package.json exports

The three output directories each feed a different condition in the exports map. Condition key order matters — put types first, default last:

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

Hazard Call-Outs

HAZARD PREVENTION — moduleResolution: "Node" in TypeScript 5: The legacy node resolution strategy does not honour package.json exports conditions. TypeScript will silently fall back to the main field, causing type mismatches when consumers use a bundler that resolves correctly. Switch to "Bundler" for library source files and "NodeNext" for Node-only CLIs.

HAZARD PREVENTION — skipLibCheck: true masking peer dependency issues: This flag tells TypeScript to skip type-checking in node_modules, which hides version conflicts between your @types/* declarations and those of your consumers. Run a separate CI job with skipLibCheck: false to surface these before publishing:

npx tsc -p tsconfig.base.json --skipLibCheck false --noEmit

HAZARD PREVENTION — slow incremental builds with declaration: true: When declaration is true and isolatedModules is false, TypeScript must analyse the full dependency graph for every file to emit declarations. Setting isolatedModules: true plus verbatimModuleSyntax lets parallel transpilers (esbuild, tsup) operate safely, and decoupling type emission via emitDeclarationOnly prevents tsc from repeating JS work.

HAZARD PREVENTION — ERR_REQUIRE_ESM from shared outDir: If tsconfig.cjs.json and tsconfig.esm.json share an outDir, the .js extension collision means one format’s file may overwrite the other. The missing or wrong-format file is then loaded at runtime and throws. Enforce dist/cjs/ and dist/esm/ separation with a lint script in your CI:

[ -f dist/cjs/index.js ] && [ -f dist/esm/index.js ] || (echo "Missing output file" && exit 1)

Tooling Validation

Run these commands after every build before publishing:

# 1. Structural type correctness
npx tsc -p tsconfig.types.json --noEmit

# 2. Published package health (exports, types, files fields)
npx publint

# 3. Whether declaration files are correct for each export condition
npx attw --pack .

# 4. Alias leak check
grep -r "@internal\|@src\|@lib" dist/ && echo "LEAKED ALIAS" || echo "Clean"

Sample publint pass output:

✔ No issues found

Sample attw pass output:

┌─────────────────────────────────────────────────────────┐
│ Package analysis                                        │
├──────────────────────┬──────────────────────────────────┤
│ node10               │ 🟢 (CJS) index.d.ts              │
│ node16 (from CJS)    │ 🟢 (CJS) index.d.ts              │
│ node16 (from ESM)    │ 🟢 (ESM) index.d.ts              │
│ bundler              │ 🟢 (ESM) index.d.ts              │
└──────────────────────┴──────────────────────────────────┘

Compatibility Matrix

TypeScript moduleResolution module (CJS) module (ESM) verbatimModuleSyntax Notes
4.7 node16 / nodenext CommonJS Node16 n/a (use importsNotUsedAsValues) First TS version with native ESM support
5.0 bundler CommonJS ESNext ✅ supported bundler resolution added; replaces node16 for library sources
5.2 bundler CommonJS ESNext ✅ recommended verbatimModuleSyntax stable; replaces importsNotUsedAsValues
5.4+ bundler CommonJS ESNext ✅ recommended .d.mts / .d.cts dual-extension declarations fully supported
Node.js Native ESM CJS interop exports map Minimum recommended
18 (EOL Apr 2025) No — EOL, no security patches
20 LTS Yes — current LTS baseline
22 Yes — active

Common Pitfalls

Issue Root Cause Resolution
Leaked baseUrl / paths in published .d.ts Compiler preserves workspace aliases in emitted declarations Restrict paths to dev-only tsconfig.json; run tsc-alias post-build
Slow incremental CI builds declaration: true forces full dependency graph analysis Enable isolatedModules + verbatimModuleSyntax; decouple via emitDeclarationOnly
Mixed formats → ERR_REQUIRE_ESM CJS and ESM share outDir — one format’s .js overwrites the other Enforce dist/cjs/ and dist/esm/ separation; map both in exports
skipLibCheck: true hides peer type conflicts Compiler skips node_modules .d.ts entirely Run a separate CI job with skipLibCheck: false
“Go to Definition” shows raw .d.ts, not source declarationMap omitted Add declarationMap: true to tsconfig.types.json
attw reports ❌ for node16 (from ESM) ESM output lacks .js extensions on relative imports Add "moduleResolution": "NodeNext" to tsconfig.esm.json for Node16-targeted builds; ensure bundler output adds extensions

TypeScript Configuration & Build Tooling