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 |
Compiler Options That Reach the Consumer
Most tsconfig.json options affect only your own development experience. A smaller set is baked into what you publish, and those deserve a different level of scrutiny — changing one is a change to every consumer’s build.
strict belongs on the right-hand side for a reason authors often miss. Compiling a library without strict produces declarations in which nothing is nullable, and a consumer who does use strict mode inherits those over-confident types: your function that can return undefined is typed as always returning a value, and their null check looks unnecessary to the compiler. Publishing non-strict types into a strict ecosystem is a correctness problem, not a style preference.
lib is similarly consequential. Listing "DOM" in a library that also runs on the server means your declarations reference browser globals, and a consumer compiling a Node-only project gets errors about types they have no reason to include. Where a package genuinely spans both, the honest arrangement is separate declaration outputs per environment behind separate conditions, not one declaration set that assumes the union of all globals.
target deserves a deliberate decision rather than a default. A lower target produces more compatible but larger output, with helper functions injected for down-levelled syntax. A higher target ships the syntax you wrote, which is smaller and faster to parse, and excludes runtimes that cannot parse it. Because consumers can always down-level your output with their own build but cannot up-level it, the usual advice is to target the oldest runtime you actually support in engines — and to keep those two numbers in agreement, since a target: ES2017 build alongside engines.node: ">=20" is simply shipping larger code for no one’s benefit.
Two Configurations, One Source Tree
The tension in a library tsconfig.json is that development wants everything included and the build wants only the published surface. One file cannot serve both without compromise, so the standard arrangement is a development config that includes tests and scripts, and a build config that extends it with the extras excluded.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/__fixtures__/**"]
}
Excluding tests from the build config is not cosmetic. A test file included in the emit produces dist/index.test.d.ts, which appears in the tarball, references your test framework’s types (a dev dependency consumers do not have), and can pull the framework into the declaration graph — producing the confusing failure where installing your library makes a consumer’s build complain about missing vitest types.
rootDir deserves a mention of its own because its failure mode is so recognisable. If any included file sits outside rootDir, TypeScript silently restructures the output to preserve relative paths, and dist/index.js becomes dist/src/index.js — breaking every path in your exports map at once. The usual trigger is a shared file imported from a sibling directory, and the diagnosis is simply to look at the emitted tree:
tsc -p tsconfig.build.json --emitDeclarationOnly --listEmittedFiles | head
TSFILE: /repo/dist/index.d.ts
TSFILE: /repo/dist/client.d.ts
TSFILE: /repo/dist/types.d.ts
Three files at the top level of dist/, exactly as the exports map expects. A dist/src/ prefix in that listing means something outside rootDir got included, and no amount of adjusting the exports paths is the right fix — the include list is.
Finally, keep skipLibCheck in mind as the trade-off it is. Turning it on skips type-checking of all .d.ts files, including your dependencies’, which speeds up builds substantially and hides genuine incompatibilities between the type packages you depend on. For a library, the compromise most teams settle on is skipLibCheck: true for the everyday development config and a periodic CI job that runs with it off, so the incompatibilities are known about rather than merely deferred. The distinction matters when a consumer reports a type error originating inside one of your dependencies: with skipLibCheck on everywhere, that error is news to you, and the first hour of the investigation goes into reproducing something your own build was configured never to show you. Scheduling that unskipped run weekly rather than per-commit keeps the everyday build fast while ensuring the backlog of hidden incompatibilities never grows past a week’s worth. Treat any new failure there as a dependency problem to report upstream rather than a local one to suppress.
The Configuration Chain a Library Actually Uses
Three configuration files, each with one job, is the arrangement that stays comprehensible as a project grows.
Keeping path-bearing options out of the base file is what makes the chain portable: extends resolves some paths relative to the base and others relative to the extending file, and the simplest way to never think about that again is to have no paths in the base at all.
Pages in This Section
- target and lib Settings for Published Packages — matching the syntax you emit and the globals you assume to the runtimes you actually support.
- isolatedModules and verbatimModuleSyntax Explained — the two flags that make output identical under
tscand single-file transpilers. - Why skipLibCheck Hides Broken Published Types — keeping the build fast without shipping declarations nobody checked.
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.