TypeScript Configuration & Build Tooling
Configure tsconfig.json for dual ESM/CJS library distribution, generate .d.ts declaration files, and master modern build tools — tsup, esbuild, and Rollup — for production-ready npm packages.
This guide covers every layer of the TypeScript toolchain that matters for library authors: compiler settings, declaration file generation, dual ESM/CJS build pipelines, and publishing mechanics. It is written for developers shipping reusable packages to npm — whether that is a small utility, a component library, or a monorepo with dozens of sub-packages.
Quick Reference
| Concept | What it means | Deep dive |
|---|---|---|
tsconfig.json |
Compiler options that control output format, strictness, and declaration generation | Optimizing tsconfig.json for Library Distribution |
moduleResolution: NodeNext |
Forces extension-aware ESM import resolution matching Node.js 12+ | Path Mapping and Module Resolution Strategies |
.d.ts declaration files |
Type definitions published alongside JavaScript so consumers get IDE support | Declaration File Generation and Type Stripping |
tsup / esbuild / Rollup |
Fast bundlers that compile TypeScript and emit both ESM and CJS in one pass | Modern Build Tools: tsup, Rollup, and esbuild |
exports field |
package.json map that routes consumers to the correct format at resolution time |
exports field guide |
| Dual-package hazard | Two copies of the same module loaded simultaneously, causing singleton and instanceof failures | Navigating the dual-package hazard |
isolatedDeclarations |
TS 5.5+ flag that enables parallel .d.ts generation without full type analysis |
Declaration File Generation and Type Stripping |
| provenance attestation | Cryptographic proof linking an npm publish to its source commit via Sigstore | Covered in Publishing Workflows below |
Core Concepts
How ESM and CJS Differ at the Compiler Level
ECMAScript Modules (ESM) and CommonJS (CJS) are not interchangeable serialisation formats — they reflect different runtime execution models. ESM uses static import/export syntax that the JavaScript engine analyses before any code runs. CJS uses require() and module.exports, resolved dynamically at runtime. That distinction is what makes tree-shaking possible for ESM (bundlers see the complete export graph at build time) and impossible for CJS (the export surface is computed at runtime).
TypeScript must be told which model to target. The module compiler option controls the output syntax; moduleResolution controls how the compiler resolves input import paths. These two settings must agree with each other and with the runtime you are targeting. For modern Node.js you want both set to NodeNext:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"strict": true,
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"outDir": "./dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
verbatimModuleSyntax prevents TypeScript from silently converting import type to a runtime import, which is the class of bug that causes ERR_REQUIRE_ESM when a pure-ESM package is loaded through a CJS entry point.
The Dual Output Architecture
A library that supports both ESM and CJS consumers must ship two separate artefact trees. The exports field in package.json routes each consumer to the right file automatically. The canonical structure looks like this:
{
"name": "@example/library",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
}
}
}
The types condition must appear before default inside each branch — resolvers honour priority order and stop at the first match. A misplaced types entry causes TypeScript to fall back to an untyped resolution and report false-positive implicit any errors in downstream code.
Running two separate tsc invocations (one per format) is the safest approach for complex type topologies. For most libraries tsup covers both formats with a single config and is significantly faster.
Declaration Files and Type Stripping
Declaration files (.d.ts) are the public type surface of your package. Without them, consuming projects either infer everything from raw .js (losing most accuracy) or give up and mark your package any. The declaration: true flag tells tsc to emit a .d.ts beside every .js output. Adding declarationMap: true generates a .d.ts.map that points IDE hover and go-to-definition back to your original TypeScript source.
TypeScript 5.5 introduced isolatedDeclarations, which constrains your source to carry explicit type annotations on every exported binding. The payoff is that a build tool can generate .d.ts files for each file in parallel without running full cross-file inference — cutting declaration-emit time significantly on large codebases. See Declaration File Generation and Type Stripping for the full setup including stripInternal and the @internal JSDoc tag.
Build Tool Decision Guide
The right tool depends on what your package does and who consumes it.
tsc alone is appropriate for pure-ESM libraries that ship unbundled files and rely on the consumer’s bundler for tree-shaking. It produces the most faithful output and is the right baseline for all type checking.
tsup / esbuild excels at dual ESM + CJS distribution. A single tsup.config.ts handles both formats, declaration files, and source maps in a single fast pass. See Modern Build Tools: tsup, Rollup, and esbuild for annotated configs.
Rollup remains the best choice when you need fine-grained control over chunking, explicit external boundaries, polyfill injection, or a specific plugin chain.
Hazard / Failure-Mode Inventory
HAZARD PREVENTION —
pathsleaking into published outputRoot cause:
tsconfig.jsonpathsaliases are resolved by the TypeScript compiler during local development but are stripped from emitted JavaScript. Consumers who install your package do not have yourpathsconfig, so any import that relied on an alias becomesMODULE_NOT_FOUNDat runtime.Fix: move all runtime routing to the
package.jsonexportsfield. Reservepathsfor internal development aliases that never appear indist/.
HAZARD PREVENTION — singleton bifurcation (dual-package hazard)
Root cause: when a package ships both
./dist/esm/index.jsand./dist/cjs/index.cjswithout a wrapper, a consumer that mixesimportandrequireacross their dependency graph may load both files. Each copy initialises its own module-level singletons — React context, class registries, validators — creating two isolated instances that failinstanceofchecks and do not share state. This is the dual-package hazard.Fix: keep shared state in a separate internal package; use a CJS wrapper that re-exports the ESM build; or ship ESM-only and rely on the
require(esm)interop available in Node.js 22+.
HAZARD PREVENTION —
typescondition out of order inexportsRoot cause: resolver algorithms process condition keys top to bottom and stop at the first match. If
defaultappears beforetypes, TypeScript never sees the.d.tspath, falls back toany, and silently loses all type safety for your package’s consumers.Fix: always list
typesfirst inside each condition branch, beforeimport/require/default.
HAZARD PREVENTION —
ERR_PACKAGE_PATH_NOT_EXPORTEDRoot cause: a consumer is importing a deep path (e.g.
import x from 'my-lib/utils') that is not listed in theexportsmap. Any path not explicitly declared is blocked by the Node.js resolver.Fix: add an explicit entry to
exportsfor each public sub-path, or use a glob pattern:"./*": "./dist/esm/*.js".
HAZARD PREVENTION — missing
.d.ctsfile for CJS consumersRoot cause: when
package.jsonsets"type": "module", TypeScript infers that.d.tsfiles describe ESM. A CJS consumer loading therequirebranch needs a.d.cts(or.d.mts) file explicitly. Without it, TypeScript reports that the package has no types for the CJS resolution path.Fix: emit a separate
index.d.ctsalongsideindex.cjs.tsupdoes this automatically withdts: true.
HAZARD PREVENTION —
verbatimModuleSyntaxand type-only importsRoot cause: without
verbatimModuleSyntax, TypeScript may elide animport typeat emit time — or fail to elide it — depending on the version and flags in use. In ESM output, a retained type-only import becomes a runtime import that can triggerERR_REQUIRE_ESM.Fix: enable
verbatimModuleSyntax: trueand useimport typefor every type-only import. The compiler then enforces the distinction statically.
Module Resolution in Depth
Why NodeNext Is the Only Safe Choice for Libraries
Before Node16/NodeNext resolution modes existed, TypeScript used Node (classic Node.js) resolution, which resolves ./foo to ./foo.ts or ./foo/index.ts. Node.js ESM resolvers do not perform these automatic extensions — they require ./foo.js even when the source file is foo.ts. This mismatch is the root cause of the “module not found” class of errors in pure-ESM packages.
Setting moduleResolution: NodeNext enforces strict ESM compliance in all your source imports, which means your output will work without modification under Node.js native ESM, esbuild, and most bundlers. Strategies for managing path aliases in published packages become significantly simpler once resolution mode is locked to NodeNext.
The exports Field as the Single Source of Truth
The exports field supersedes main and module for all Node.js versions ≥ 12.7. It gives you:
- Per-format routing (
importvsrequireconditions) - Per-environment routing (
browservsnodevsworkerconditions) - Encapsulation of internal paths (anything not listed is unreachable)
Older fields (main, module, browser) remain useful as fallbacks for bundlers that do not yet read exports — most notably some legacy webpack 4 configurations and older versions of Rollup — but they must point to the same files declared in exports to avoid divergence.
tsup Configuration for Dual Output
The following config covers the full dual-output setup for most library use cases:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'], // emit both formats in one pass
dts: true, // generate .d.ts and .d.cts automatically
splitting: false, // disable code splitting for library bundles
sourcemap: true,
clean: true, // wipe dist/ before each build
outDir: 'dist',
target: 'node20',
external: ['react', 'react-dom'], // never bundle peer dependencies
});
After build, dist/ should contain:
dist/
index.js ← ESM
index.cjs ← CJS
index.d.ts ← types for ESM consumers
index.d.cts ← types for CJS consumers
Validate the output before publishing with publint and are-the-types-wrong:
npx publint
npx attw --pack .
Both tools check that your exports map is consistent, that every declared file exists, and that TypeScript resolves types correctly for each condition.
Cross-Environment Validation and CI
Type checking in CI must cover more than the happy path. Run tsc --noEmit against a project-references config that includes environment-specific lib settings:
name: Validate & Build
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck # tsc --noEmit, no emit
- run: pnpm build
- run: npx publint
- run: npx attw --pack .
Note that the CI matrix now targets Node.js 20 and 22. Node.js 18 reached end-of-life in April 2025 and no longer receives security updates.
Publishing Workflow and Provenance
Pre-flight validation scripts should run before every publish:
{
"scripts": {
"prepublishOnly": "pnpm run build && pnpm run typecheck && npx publint",
"publish:dry": "npm publish --dry-run --access public"
},
"engines": {
"node": ">=20.0.0"
}
}
Supply chain integrity requires cryptographic verification of published artefacts. npm provenance, available in npm 9.5+ and GitHub Actions, links a build directly to its source commit via Sigstore. Enable it with:
npm publish --access public --provenance
This flag generates a Sigstore attestation that the npm registry stores alongside the package. Consumers running npm audit signatures can verify the package was built from the declared repository — preventing the class of supply-chain attack where a compromised account publishes a modified tarball.
Automated versioning with changesets or semantic-release handles version bumping, changelog generation, and Git tagging. Both integrate cleanly with GitHub Actions OIDC tokens, which are the recommended credential source for provenance-enabled publishes.
Topic Index
Optimizing tsconfig.json for Library Distribution
Full compiler option reference for library authors: module, moduleResolution, target, strictness flags, and how to wire up parallel ESM and CJS compiler configurations without flag drift.
Read guide →
Path Mapping and Module Resolution Strategies
How to use paths safely during development, why it must never appear in published output, and how the exports field replaces it with runtime-safe routing that bundlers and Node.js honour.
Read guide →
Declaration File Generation and Type Stripping
Generating accurate .d.ts files, using isolatedDeclarations for faster parallel builds, stripInternal to hide private APIs, and running tsc --noEmit for type-only validation without emitting JavaScript.
Read guide →
Modern Build Tools: tsup, Rollup, and esbuild
Annotated configurations for each tool, a direct comparison of build times and output fidelity, migration path from tsc to esbuild, and how to wire tsup to emit correct dual-output artefacts.
Read guide →
Monorepo & Workspace Publishing
Publishing multiple packages from a pnpm or npm workspace with TypeScript project references, composite builds, and correct cross-package exports for each published package.
Read guide →
Frequently Asked Questions
Should I use TypeScript’s paths compiler option in a published library?
No. paths is resolved only by the TypeScript compiler during local compilation and is stripped from emitted JavaScript. Published packages must use the package.json exports field for reliable sub-path routing across Node.js, bundlers, and TypeScript consumers.
Does isolatedDeclarations break incremental builds?
No — it enables faster incremental builds. Because each file can have its .d.ts generated in isolation, tools can parallelise declaration emit and avoid re-emitting files whose dependencies have not changed.
Is a bundler required for TypeScript library distribution?
Not always. Many modern libraries ship unbundled .js files generated by tsc or esbuild without consolidating them into a single file. Bundling is necessary when you need polyfill injection, asset inlining, or strict format normalisation for environments that cannot handle ES2022 syntax. For tree-shaking, unbundled ESM is often better because the consumer’s bundler can eliminate unused exports from your module graph more aggressively.
How do I verify my exports map is correct before publishing?
Run npx publint to validate that every declared file exists and that conditions are in priority order. Run npx attw --pack . (are-the-types-wrong) to verify TypeScript resolves types correctly for each entry point and format combination.
Can Node.js 22 require() an ESM package directly?
Yes. Node.js 22.12+ supports require() of synchronous ES modules as an experimental feature (enabled by default in 23+). Packages with top-level await or dynamic import() in their module graph still require the ESM loader. Until this lands in LTS, maintaining the CJS wrapper or a dedicated .cjs entry is the safest option for broad compatibility.
Related
- Module System Fundamentals & Dual-Package Resolution — ESM vs CJS caching model, the
exportsfield, and how Node.js resolves modules in practice. - Mastering the package.json
exportsField — Condition key ordering, wildcard patterns, and per-environment routing. - Navigating the Dual-Package Hazard — Why singleton bifurcation happens and the three strategies for preventing it.
- Tree-Shaking & Bundle Optimization — How ESM module boundaries enable dead-code elimination and why CJS defeats it.
- Implementing the
sideEffectsFlag Correctly — Marking your library as side-effect-free so bundlers can remove unused exports without risk.