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

Declaration Files Are a Public API, Not a Build Artefact

The .d.ts files in a published package are read by every consumer’s editor and compiler, and they break in ways the runtime code never does. The most useful mental shift is to treat them as a versioned interface: reviewable in pull requests, regression-tested, and changed under the same semver rules as the functions they describe.

Three ways declarations go wrong A correct runtime build can ship with declarations that leak internal alias paths, that expose types the author considered private, or whose module format does not match the runtime file served under the same condition. The runtime build passes. The types still break consumers. leaked paths import("@/internal/types") survives into the .d.ts consumer has no such alias symptom: cannot find module cause: path mapping is a compile-time-only feature accidental exposure a returned internal type becomes part of the API renaming it is now breaking symptom: none, until later cause: inference pulls types you never exported by name format mismatch one .d.ts for both builds ESM types over a CJS file default export appears usable symptom: runtime undefined cause: the types condition points at the wrong file

The middle failure is the quietest and the most expensive. TypeScript infers return types, and an inferred type that mentions an internal interface pulls that interface into the declaration output whether or not you exported it. The interface is now part of your public API in every practical sense: consumers can reference it, tooling surfaces it in autocomplete, and renaming it breaks compilation downstream. The fix is to annotate public function signatures explicitly rather than relying on inference at the API boundary — which also makes the declaration output stable against unrelated refactors inside the function body.

// inference leaks InternalCacheEntry into the .d.ts
export function getEntry(key: string) {
  return cache.get(key);            // returns InternalCacheEntry | undefined
}

// explicit annotation keeps the surface deliberate
export interface Entry { value: string; expiresAt: number; }
export function getEntry(key: string): Entry | undefined {
  const hit = cache.get(key);
  return hit && { value: hit.v, expiresAt: hit.exp };
}

Regression-Testing the Types You Ship

Runtime behaviour has tests; type behaviour usually does not, which is why type regressions ship. Two kinds of test close the gap, and both are cheap enough to add to an existing suite.

The first asserts that valid usage compiles and invalid usage does not. @ts-expect-error is the whole mechanism — the comment fails compilation if the line beneath it turns out to be error-free, so it catches a type that has become too permissive:

// types.test-d.ts — compiled by tsc, never executed
import { createClient, type Entry } from "@scope/my-library";

const client = createClient({ baseUrl: "https://example.com", retries: 2 });
const entry: Entry | undefined = client.get("key");

// @ts-expect-error — retries must be a number
createClient({ baseUrl: "https://example.com", retries: "2" });

// @ts-expect-error — baseUrl is required
createClient({ retries: 1 });
npx tsc --noEmit --strict types.test-d.ts && echo "type surface unchanged"

The second guards against unintended API growth by snapshotting the declaration surface. Rolling the declarations into a single file and committing it makes every change to the public types visible in a diff — the same discipline as a committed size baseline, applied to the API:

npx api-extractor run --local
git diff --exit-code etc/my-library.api.md || {
  echo "public type surface changed — review and commit the new report"; exit 1;
}
Warning: The .api.md report has changed:
+ export interface Entry { value: string; expiresAt: number; }
- export interface Entry { value: string; }

A reviewer seeing that diff can ask the semver question at the right moment, rather than discovering after release that a patch version added a required property.

The two tests answer different questions and are worth having both. The @ts-expect-error suite proves that specific behaviours hold; the API report proves that nothing changed without you noticing. Together with attw --pack ., which proves the declarations are reachable under every consumer’s resolution mode, they cover the three ways published types go wrong: incorrect, unintentionally changed, and correctly written but unreachable. Each of the three has produced its own genre of issue report, and none of them is caught by the runtime test suite that most libraries already run on every commit.


The Declaration Pairing Rule

Every runtime file served by a condition needs a declaration file of the matching module format, named in that same condition.

Declaration and runtime file pairing The import condition pairs index.mjs with index.d.ts. The require condition pairs index.cjs with index.d.cts. Sharing a single declaration file across both conditions is the common mistake. One declaration file per runtime format, never shared "import" condition index.mjs + index.d.ts "require" condition index.cjs + index.d.cts the common mistake one index.d.ts named by both conditions — CommonJS consumers get ESM types

A shared declaration file is the failure attw was written to catch, and it is worth running against the packed tarball rather than the source tree, because the pairing can look correct in dist/ and still be wrong in what ships.


Further Reading



TypeScript Configuration & Build Tooling