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.

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.

Four artefacts and the checks between them ESM JavaScript, CommonJS JavaScript, declaration files for each format, and the package manifest. publint checks the manifest against the files, arethetypeswrong checks the declarations against each consumer mode, and a sandbox install checks the runtime behaviour of both formats. Four artefacts, three checks that keep them honest ESM JavaScript dist/index.mjs real import/export CJS JavaScript dist/index.cjs real module.exports declarations .d.ts and .d.cts one per format manifest exports conditions routes all three publint manifest ↔ files do the paths exist? attw declarations ↔ consumer modes do the types resolve? sandbox install JavaScript ↔ reality does it actually run?

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.ts beside .mjs, .d.cts beside .cjs, each named in its own condition. Checked by attw --pack ..
  • Is strict on? If not, your published types under-report nullability for every strict consumer. Checked by reading one line of tsconfig.json.
  • Does target match the runtimes in engines? 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 one grep over the emitted files, declarations included.
  • Are tests and fixtures excluded from the emit? A stray .test.d.ts in the tarball drags dev-only types into consumers’ builds. Checked by npm 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



← Back to home