Optimizing tsconfig.json for Library Distribution
Configure tsconfig.json for dual ESM/CJS library distribution with strict type emission, declaration maps, moduleResolution bundler mode, and cross-environment validation.
Distributing a TypeScript library requires precise compiler configuration to guarantee compatibility across diverse consumer environments. The architecture of your TypeScript Configuration & Build Tooling directly dictates whether downstream projects experience seamless integration or encounter cascading resolution failures. Optimizing tsconfig.json for library distribution demands strict control over module formats, type emission, and resolution boundaries. The following guide provides production-ready configurations, CI validation patterns, and explicit hazard-prevention steps for shipping robust dual-format packages.
Core Compiler Flags for Dual ESM/CJS Output
Modern Node.js and bundler ecosystems require explicit separation of CommonJS (CJS) and ES Module (ESM) artifacts. A single tsconfig.json cannot safely output both formats to the same directory without risking runtime conflicts or incorrect __esModule interop flags.
Baseline Configuration
Start with a shared base configuration that enforces strict type safety and modern resolution standards:
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"strictNullChecks": true,
"verbatimModuleSyntax": true,
"moduleResolution": "NodeNext",
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Dual-Format Overrides
Extend the base config to isolate outputs. This prevents Node.js from misinterpreting require() calls in ESM contexts or import statements in CJS contexts.
// 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"
}
}
️ Hazard Prevention: Never compile ESM and CJS to the same outDir. Node.js uses file extensions and package.json exports to determine module type. Mixing formats in one directory triggers ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUND in downstream consumers. Always align target with your minimum supported Node.js LTS version (currently ES2022 for Node 18+).
Module Resolution and Path Aliasing in Libraries
Internal path aliases (baseUrl and paths) are highly convenient during development but become fatal when published. Consumers lack your local workspace configuration, causing immediate MODULE_NOT_FOUND errors upon installation.
Safe Resolution Strategy
Strip or transform aliases before publishing. When configuring resolution strategies, consult Path Mapping and Module Resolution Strategies to understand how internal aliases interact with Node.js and bundler resolution, emphasizing transformation requirements for published packages.
// tsconfig.build.json (Used strictly for publishing)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@internal/*": ["src/*"]
},
"moduleSuffixes": [".js", ".mjs", ".cjs"],
"customConditions": ["production", "default"]
}
}
Alias Transformation Pipeline
Use a post-compilation step to rewrite paths to relative imports. Tools like tsc-alias or bundler plugins handle this automatically.
// package.json scripts
{
"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"
}
}
️ Hazard Prevention: Never ship paths or baseUrl in your published tsconfig.json. If a consumer’s project inherits your config via extends, their compiler will attempt to resolve @internal/ against their own filesystem. Always validate compiled .d.ts and .js outputs with grep -r "@internal" dist/ to ensure zero alias leakage.
Declaration File Emission and Type Stripping
Type definitions are the primary interface for library consumers. Optimizing .d.ts generation reduces package bloat while preserving IDE navigation accuracy. For a comprehensive breakdown of the mechanics behind .d.ts emission, declaration maps, and decoupling type generation from JavaScript transpilation, refer to Declaration File Generation and Type Stripping.
Parallel Type & JS Pipeline
Decouple type generation from JavaScript transpilation to accelerate CI throughput. Use emitDeclarationOnly to generate types in isolation, then run esbuild or tsup for fast JS bundling.
// tsconfig.types.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist/types",
"rootDir": "./src"
}
}
#!/bin/bash
# build.sh - Parallel execution for CI/CD
set -e
# Run type generation in background
tsc -p tsconfig.types.json &
TSC_PID=$!
# Run JS transpilation concurrently
esbuild src/index.ts --bundle --format=esm --outdir=dist/esm --sourcemap &
ESM_PID=$!
esbuild src/index.ts --bundle --format=cjs --outdir=dist/cjs --sourcemap &
CJS_PID=$!
# Wait for all processes
wait $TSC_PID $ESM_PID $CJS_PID
echo "✅ Build complete. Artifacts isolated in dist/"
️ Hazard Prevention: Omitting declarationMap: true breaks IDE “Go to Definition” navigation across package boundaries. Consumers will only see raw .d.ts paths instead of your original source files. Additionally, avoid skipLibCheck: false in the primary build config unless explicitly validating peer dependency compatibility, as it drastically increases compile time without improving consumer safety.
CI/CD Validation and Cross-Environment Type Checking
Library authors must verify type compatibility across Node.js, browser, and edge runtimes before publishing. Relying on a single tsc execution masks environment-specific global definitions and module resolution quirks.
Matrix Type Validation
Run parallel tsc --noEmit jobs targeting distinct runtime profiles. This catches ambient type pollution and missing lib entries early.
# .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, edge]
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 --types node
elif [ "${{ matrix.env }}" == "edge" ]; then
npx tsc --noEmit --lib ES2022 --strict --types cloudflare-workers
else
npx tsc --noEmit --lib ES2022 --strict
fi
️ Hazard Prevention: Always enforce strict: true and strictNullChecks: true in validation gates. Disabling them allows implicit any types to leak into published declarations, causing downstream type errors when consumers enable strict mode. Pin lib explicitly to prevent compiler defaults from shifting between TypeScript versions.
Common Pitfalls & Resolutions
| Issue | Root Cause | Production Resolution |
|---|---|---|
Leaking baseUrl and paths into published artifacts |
Compiler preserves workspace aliases in emitted .d.ts files. |
Strip aliases during build using tsc-alias or a custom AST transformer. Avoid paths in distribution configs entirely; use relative imports in src/. |
Slow incremental builds due to declaration: true without isolatedModules |
TypeScript must parse entire dependency graphs to emit declarations safely. | Enable isolatedModules: true alongside verbatimModuleSyntax. This enforces single-file compilation boundaries and unlocks parallel CI type emission. |
Mixed module formats causing ERR_REQUIRE_ESM in downstream consumers |
CJS and ESM artifacts share outDir, confusing Node.js module loader. |
Enforce strict directory separation (dist/cjs/, dist/esm/). Map explicitly in package.json exports with "import" and "require" conditions. |
skipLibCheck: true masking type incompatibilities in peer dependencies |
Compiler ignores .d.ts in node_modules, hiding breaking API changes. |
Run a dedicated CI validation job with skipLibCheck: false and strict: true against the exact peer dependency versions before merging. |