Without .d.ts declaration files, consumers of your library lose IDE autocompletion, cannot run tsc against your API, and may see Could not find a declaration file for module errors at build time — even when your JavaScript runs perfectly. The problem compounds with dual ESM/CJS packages: each output format needs its own correctly-pathed declarations, and a single misconfiguration silently breaks one entire module system for every downstream project. This page covers how to configure declaration emission, choose a bundler-driven extraction strategy, flatten path aliases, and validate the output before publishing — across Node.js 18+, TypeScript 4.9–5.x, and the most common bundlers.

Prerequisites


How Declaration Emission Fits the Build Pipeline

The diagram below shows where .d.ts files appear in a dual ESM/CJS build and how they map to package.json exports. Understanding this flow prevents the most common class of “types are missing” bugs.

Declaration file generation pipeline Source TypeScript files flow through tsc (for .d.ts) and a bundler such as esbuild or tsup (for JS), producing dist/types/*.d.ts alongside dist/esm/*.js and dist/cjs/*.js. The package.json exports map wires types, import, and require conditions to each output. src/ index.ts utils.ts types.ts tsc emitDeclarationOnly Bundler esbuild / tsup dist/types/ index.d.ts + .d.ts.map dist/esm/ index.js (ESM) dist/cjs/ index.cjs (CJS) exports map "types": "./dist/types/…" "import": "./dist/esm/…" "require": "./dist/cjs/…" "default": "./dist/esm/…"

Canonical Configuration Block

The single most important configuration is the tsconfig.json that governs declaration emit. Everything downstream — bundler config, CI scripts, package.json exports — depends on this being correct.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "outDir": "./dist/types",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": false
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"]
}

Key option decisions:

  • declaration: true — emit .d.ts for every source file; required for consumers to see your types.
  • declarationMap: true — emit .d.ts.map so IDEs can navigate from consumer call-sites back to your original .ts source.
  • emitDeclarationOnly: truetsc writes only declarations; a separate bundler step (esbuild, Rollup, tsup) produces the JavaScript. Prevents double-compilation and keeps build times low.
  • verbatimModuleSyntax: true — enforces import type for type-only imports, preventing runtime-invisible imports from appearing in emitted JavaScript and causing ESM/CJS interop issues.
  • isolatedModules: true — each file must be compilable in isolation, which aligns with esbuild/SWC’s single-file transform model and surfaces errors that would otherwise appear only in consumers.
  • skipLibCheck: false — validates third-party declarations; leave it off so broken upstream types don’t hide in your distribution.

Step-by-Step Implementation

Step 1 — Add declaration emit to tsconfig.json

Start from the canonical block above. If you already have a tsconfig.json, add or update these four options without touching the rest:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "outDir": "./dist/types"
  }
}

Run tsc once to verify output:

npx tsc --project tsconfig.json

Expected directory structure after this step:

dist/
  types/
    index.d.ts
    index.d.ts.map
    utils.d.ts
    utils.d.ts.map

HAZARD PREVENTION: If dist/types/ is empty after running tsc, the most common cause is noEmit: true still present elsewhere in your config or in a config that extends yours. Search with grep -r "noEmit" tsconfig*.json. Remove noEmit: true from the config used for declaration generation; it overrides emitDeclarationOnly and suppresses all file output silently.

Step 2 — Wire declarations into package.json exports

The exports field must include a types condition as the first key in each entry so TypeScript resolvers encounter it before the runtime conditions:

{
  "name": "my-library",
  "version": "1.0.0",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    }
  },
  "files": ["dist"]
}

The types condition must come before import, require, and default — TypeScript 4.7+ reads conditions in declaration order and stops at the first match.

HAZARD PREVENTION: A types key at the top-level of package.json is a legacy fallback used by TypeScript < 4.7 and tools that do not read exports. Keep it, but never rely on it as the sole declaration pointer for modern packages. Consumers using "moduleResolution": "Bundler" or "NodeNext" read only the exports map.

Step 3 — Flatten internal path aliases before emit

If your tsconfig.json uses baseUrl and paths (for example @internal/*), the compiler copies those alias references verbatim into emitted .d.ts files. Consumers who do not share your tsconfig cannot resolve these aliases and will see errors like Cannot find module '@internal/utils'.

The reliable fix is tsc-alias, which rewrites alias references to relative paths after tsc runs:

npm install --save-dev tsc-alias

Update your build script:

{
  "scripts": {
    "build:types": "tsc --project tsconfig.json && tsc-alias --project tsconfig.json"
  }
}

tsc-alias reads the same paths config and replaces every alias with the computed relative path. See Path Mapping and Module Resolution Strategies for the complete alias-flattening workflow and edge cases with monorepo setups.

HAZARD PREVENTION: tsc-alias must run after tsc, not before. If you run them in the wrong order, tsc-alias will find no files to rewrite because tsc has not yet written the .d.ts output. Chain them with && or use a sequential build tool step.

Step 4 — Choose a bundler-driven extraction workflow

For most libraries, one of three patterns covers declaration generation alongside JavaScript bundling:

tsup (recommended for simple APIs)

tsup wraps tsc declaration emit behind its dts: true option while using esbuild for JavaScript. It handles both ESM and CJS outputs in one configuration:

// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,           // runs tsc under the hood; outputs .d.ts alongside JS
  clean: true,
  external: ['react', 'react-dom'],
  sourcemap: true,
});

Expected output:

dist/
  index.js       (ESM)
  index.cjs      (CJS)
  index.d.ts     (shared declarations)
  index.d.ts.map

HAZARD PREVENTION: tsup’s dts: true spawns a separate tsc process using your project’s tsconfig.json. If that config has noEmit: true, declaration generation silently fails — the .d.ts files are never written. Ensure noEmit is absent from the config tsup resolves, or pass --dts-resolve to bundle external type dependencies into the output declaration file.

Rollup + rollup-plugin-dts (for libraries with complex re-exports)

rollup-plugin-dts performs tree-shaking on declaration files and can merge multiple .d.ts files into a single bundled declaration — useful for libraries with large internal type graphs or conditional exports:

// rollup.config.mjs
import dts from 'rollup-plugin-dts';
import { defineConfig } from 'rollup';

export default defineConfig([
  // JS build handled by a separate Rollup config or esbuild
  {
    input: 'dist/types/index.d.ts',  // tsc output consumed here
    output: { file: 'dist/index.d.ts', format: 'es' },
    plugins: [dts()],
    external: [/node_modules/],
  },
]);

HAZARD PREVENTION: Rollup’s tree-shaking removes declarations it considers unreachable. If your library re-exports types that consumers need for type augmentation or declare module patterns, mark those entry points in external or verify they appear in the rollup entry. Bundler type stripping that removes ambient declarations breaks global augmentations without any build error.

Parallel tsc + esbuild (for performance-critical monorepos)

esbuild’s --loader=ts strips types at sub-millisecond speed but does not write .d.ts files. Run tsc --emitDeclarationOnly in parallel for declarations and let esbuild handle JavaScript:

# package.json scripts
{
  "scripts": {
    "build": "run-p build:types build:esm build:cjs",
    "build:types": "tsc --project tsconfig.json",
    "build:esm": "esbuild src/index.ts --bundle=false --format=esm --outdir=dist/esm --platform=node",
    "build:cjs": "esbuild src/index.ts --bundle=false --format=cjs --outdir=dist/cjs --platform=node --out-extension:.js=.cjs"
  }
}

This approach appears in detail in Modern Build Tools: tsup, Rollup, and esbuild.

Step 5 — Validate before publishing

Validation must run in CI against the actual dist/ directory, not the source. See the full validation workflow in the next section.


Tooling Validation

Run these commands against the built output before every npm publish:

# 1. Type-check the generated declarations without re-emitting
tsc --noEmit --project tsconfig.types.json --skipLibCheck false

# 2. Check package.json exports correctness and field ordering
npx publint

# 3. Verify declaration files resolve correctly under all TypeScript moduleResolution modes
npx @arethetypeswrong/cli --pack .

tsconfig.types.json targets only the generated declarations:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
    "declaration": false,
    "skipLibCheck": false,
    "types": ["node"]
  },
  "include": ["dist/types/**/*.d.ts"]
}

Sample passing attw output:

┌─────────────────────────────────────────────────────┐
│ my-library                                           │
├──────────────────────┬──────────────────────────────┤
│ "." (ESM)            │ ✓ types resolve correctly     │
│ "." (CJS)            │ ✓ types resolve correctly     │
└──────────────────────┴──────────────────────────────┘

Sample failing attw output (missing types condition):

┌─────────────────────────────────────────────────────┐
│ my-library                                           │
├──────────────────────┬──────────────────────────────┤
│ "." (ESM)            │ ✗ No types found               │
└──────────────────────┴──────────────────────────────┘

A missing types condition in exports is the fix — add it as the first key per Step 2 above.

CI workflow:

# .github/workflows/validate-types.yml
name: Validate Types
on: [push, pull_request]
jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - run: npx tsc --noEmit --project tsconfig.types.json --skipLibCheck false
      - run: npx publint
      - run: npx @arethetypeswrong/cli --pack .

HAZARD PREVENTION: Setting skipLibCheck: true in the validation config defeats the purpose — it silences exactly the third-party type resolution errors that will break consumers. Keep skipLibCheck: false in tsconfig.types.json even if your main tsconfig.json uses true for developer convenience.


Compatibility Matrix

TypeScript version moduleResolution needed exports types condition verbatimModuleSyntax emitDeclarationOnly
4.7–4.8 NodeNext or Node16 Supported via typesVersions fallback Not available Available
4.9 NodeNext Supported Not available Available
5.0–5.1 NodeNext or Bundler Supported Available (replaces importsNotUsedAsValues) Available
5.2+ NodeNext or Bundler Supported; types condition read natively Available Available
Node.js strip-types (22.6+) N/A (runtime strips annotations) N/A Respected at runtime No .d.ts produced

Node.js --experimental-strip-types (Node 22.6+) and --strip-types (Node 23.0+) enable running .ts files directly, but they produce no .d.ts output. For library distribution, declaration emission through tsc remains mandatory regardless of runtime type stripping. See Generating Accurate .d.ts Files with TypeScript 5.4 for version-specific declaration emit changes.

Bundler Native .d.ts emit Recommended approach Notes
tsup Via internal tsc dts: true Fastest setup; use --dts-resolve for external type bundling
Rollup Via rollup-plugin-dts Two-pass: tsc then rollup Best for tree-shaken declaration bundles
esbuild None Parallel tsc --emitDeclarationOnly Fastest JS build; separate declaration step required
Vite (lib mode) Via vite-plugin-dts Plugin wraps tsc Works; less control than direct tsc
Webpack None External tsc step Webpack is rarely the right choice for library declaration emit

Further Reading



TypeScript Configuration & Build Tooling