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.

TypeScript Build Tool Decision Tree A flowchart guiding library authors through selecting tsc, tsup/esbuild, or Rollup based on their output requirements. Start Need dual ESM + CJS output? (consumers on both module systems) No Yes Polyfills or asset inlining needed? No Yes Max plugin flexibility or max build speed? Flex Speed tsc Rollup Rollup tsup / esbuild ESM-only or unbundled lib Complex asset or polyfill graph Full plugin ecosystem Most dual-output libraries Most dual-output packages land on tsup or Rollup. Pure-ESM utilities can stay with tsc alone.

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 — paths leaking into published output

Root cause: tsconfig.json paths aliases are resolved by the TypeScript compiler during local development but are stripped from emitted JavaScript. Consumers who install your package do not have your paths config, so any import that relied on an alias becomes MODULE_NOT_FOUND at runtime.

Fix: move all runtime routing to the package.json exports field. Reserve paths for internal development aliases that never appear in dist/.


HAZARD PREVENTION — singleton bifurcation (dual-package hazard)

Root cause: when a package ships both ./dist/esm/index.js and ./dist/cjs/index.cjs without a wrapper, a consumer that mixes import and require across 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 fail instanceof checks 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 — types condition out of order in exports

Root cause: resolver algorithms process condition keys top to bottom and stop at the first match. If default appears before types, TypeScript never sees the .d.ts path, falls back to any, and silently loses all type safety for your package’s consumers.

Fix: always list types first inside each condition branch, before import/require/default.


HAZARD PREVENTION — ERR_PACKAGE_PATH_NOT_EXPORTED

Root cause: a consumer is importing a deep path (e.g. import x from 'my-lib/utils') that is not listed in the exports map. Any path not explicitly declared is blocked by the Node.js resolver.

Fix: add an explicit entry to exports for each public sub-path, or use a glob pattern: "./*": "./dist/esm/*.js".


HAZARD PREVENTION — missing .d.cts file for CJS consumers

Root cause: when package.json sets "type": "module", TypeScript infers that .d.ts files describe ESM. A CJS consumer loading the require branch 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.cts alongside index.cjs. tsup does this automatically with dts: true.


HAZARD PREVENTION — verbatimModuleSyntax and type-only imports

Root cause: without verbatimModuleSyntax, TypeScript may elide an import type at 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 trigger ERR_REQUIRE_ESM.

Fix: enable verbatimModuleSyntax: true and use import type for 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 (import vs require conditions)
  • Per-environment routing (browser vs node vs worker conditions)
  • 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.



← Back to home