Optimizing tsconfig.json for Library Distribution
Configure tsconfig.json for dual ESM/CJS library distribution: strict type emission, declaration maps, bundler moduleResolution, and cross-environment validation.
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
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: OmittingdeclarationMap: truebreaks “Go to Definition” across package boundaries in every IDE. Consumers clicking into your library see raw.d.tscontent 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 legacynoderesolution strategy does not honourpackage.jsonexportsconditions. TypeScript will silently fall back to themainfield, 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: truemasking peer dependency issues: This flag tells TypeScript to skip type-checking innode_modules, which hides version conflicts between your@types/*declarations and those of your consumers. Run a separate CI job withskipLibCheck: falseto surface these before publishing:npx tsc -p tsconfig.base.json --skipLibCheck false --noEmit
HAZARD PREVENTION — slow incremental builds with
declaration: true: Whendeclarationis true andisolatedModulesis false, TypeScript must analyse the full dependency graph for every file to emit declarations. SettingisolatedModules: trueplusverbatimModuleSyntaxlets parallel transpilers (esbuild, tsup) operate safely, and decoupling type emission viaemitDeclarationOnlyprevents tsc from repeating JS work.
HAZARD PREVENTION —
ERR_REQUIRE_ESMfrom sharedoutDir: Iftsconfig.cjs.jsonandtsconfig.esm.jsonshare anoutDir, the.jsextension collision means one format’s file may overwrite the other. The missing or wrong-format file is then loaded at runtime and throws. Enforcedist/cjs/anddist/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 |
Related Topics
- Declaration File Generation and Type Stripping — deep dive into
emitDeclarationOnly,.d.mts/.d.ctsextensions, and stripping@internalsymbols from published types. - Path Mapping and Module Resolution Strategies — when
bundlervsnodenextmoduleResolution matters and howpathsinteract with consumer toolchains. - Modern Build Tools: tsup, Rollup, and esbuild — how each bundler consumes your tsconfig and where TypeScript’s role ends and the bundler’s begins.
- Mastering the package.json exports Field — condition key ordering,
typesplacement, and wiringdist/cjsanddist/esminto a valid exports map. - Navigating the Dual Package Hazard — why separate
dist/cjsanddist/esmdirectories are the correct fix and how singleton state breaks when both formats load.