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.
The Four Artefacts a TypeScript Library Ships
A published TypeScript package is four separate things travelling together, and almost every distribution bug is a disagreement between two of them. Naming the four makes the failures diagnosable rather than mysterious.
The reason no single tool covers all three checks is that they inspect fundamentally different things — a manifest, a type graph, and a running process. Skipping any one of them leaves a specific hole: without publint you ship paths that do not exist; without attw you ship declarations nobody can reach; without a sandbox install you ship code that fails on first import for reasons no static analysis would have surfaced.
Where TypeScript’s Model Meets Node’s
TypeScript type-checks against a model of how modules resolve; Node.js implements the real thing. Nearly every configuration decision in this section exists to keep those two in agreement, and the places they can drift are worth listing explicitly.
Extensions. TypeScript source imports ./utils.js while the file on disk is utils.ts. This looks wrong the first time and is correct: the specifier describes the emitted file, because that is what will exist at runtime. Under moduleResolution: nodenext this is enforced; under bundler it is not, which is the single most common way a library passes its own type check and fails on a consumer’s machine.
Conditions. The compiler applies the types condition — and, in newer releases, respects the surrounding import/require context — when resolving a dependency’s declarations. If a dependency’s types condition is missing or misordered, TypeScript can end up describing the wrong build of that dependency to you. Passing attw on your own package does not protect you from a dependency that fails it.
Formats. A .d.ts file’s own module format follows the same rules as a .js file: the extension, or the nearest package.json type field. A declaration file written in ESM style but served under the require condition describes an interface that CommonJS consumers cannot actually use, and TypeScript will happily let them write code against it that fails at runtime.
Node built-ins. @types/node is a dev dependency of your library, not of your consumer’s project. If your published declarations reference Buffer, process, or a node: module in a public signature, consumers must install @types/node themselves or their build breaks. Either keep Node types out of the public surface, or document the requirement — and prefer the first, since a library whose types require a Node environment is implicitly a Node-only library even when its runtime code is portable.
# does the public surface leak Node types?
grep -rEn '\b(Buffer|NodeJS\.|process\.)' dist/*.d.ts | head
An empty result means a browser-only consumer can compile against your package without installing anything extra — a small compatibility win that costs nothing to check and is easy to lose in a refactor.
A Configuration Review Checklist
Before a release, five questions catch the overwhelming majority of TypeScript distribution problems. Each has a one-line answer and a one-line check.
- Do the declaration files match their runtime files in format?
.d.tsbeside.mjs,.d.ctsbeside.cjs, each named in its own condition. Checked byattw --pack .. - Is
stricton? If not, your published types under-report nullability for every strict consumer. Checked by reading one line oftsconfig.json. - Does
targetmatch the runtimes inengines? A mismatch means either shipping unnecessary down-levelling or claiming support you do not have. Checked by comparing two fields. - Did any alias survive into
dist/? Aliases are compile-time only and never rewritten. Checked by onegrepover the emitted files, declarations included. - Are tests and fixtures excluded from the emit? A stray
.test.d.tsin the tarball drags dev-only types into consumers’ builds. Checked bynpm pack --dry-run.
None of these takes longer than a few seconds, and together they replace the much slower loop of publishing, waiting for a consumer to hit the problem, and shipping a patch. The clusters below go into each area in depth — but a release that can answer these five questions is already ahead of most packages on the registry. It is worth writing the five checks into a single npm run check script rather than a document, because a checklist that lives in prose is followed for the first three releases and forgotten by the tenth, whereas a script that fails the build is followed forever. The mechanics of wiring that script into the publish lifecycle, so it runs whether the release comes from a maintainer’s laptop or from a pipeline, are covered in the validation guidance under CI and publishing. The same script then serves double duty as documentation: a new contributor reading it learns, in about twenty seconds, exactly which properties of the published package this project considers non-negotiable, which is far more than a paragraph of prose in a contributing guide usually conveys, and it never goes out of date the way that paragraph inevitably does.
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.
Source Maps and Debugging Published Packages
What a consumer sees when something goes wrong inside your package: source maps that resolve stack traces to TypeScript, declaration maps that make go-to-definition reach real code, and the privacy and size trade-offs of shipping sources. Read the guide
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.