A published package emits one declaration file per source module, and two problems follow. A consumer’s editor loads several hundred small files to resolve one import, and the declarations reference internal paths that mean nothing outside your repository:

// dist/index.d.ts
import type { InternalCacheEntry } from "./internal/cache.js";
import type { RetryPolicy } from "./internal/retry.js";
export declare function createClient(options: ClientOptions): Client;

A declaration bundler flattens that tree into one self-contained file, and produces an API report you can review as part of a pull request.


Root Cause

tsc emits declarations that mirror the source structure: one .d.ts per module, with imports between them. That is correct output and a poor distribution format. The consumer’s language server must resolve and parse every file in the graph, and every internal module path becomes part of what you publish — so moving internal/cache.ts changes the published types even though nothing about the public API changed.

Bundling resolves the graph once, at build time, and emits a single file containing every type reachable from the entry point with no internal imports left.


Minimal Reproduction

npx tsc -p tsconfig.build.json --emitDeclarationOnly
find dist -name '*.d.ts' | wc -l
grep -c 'from "\./' dist/index.d.ts
147
9

One hundred and forty-seven declaration files, nine internal imports in the entry declaration alone — all of it published, all of it loaded by every consumer’s editor.


What Flattening Changes

Declaration tree versus flattened bundle An emitted declaration tree contains one file per module with imports between them. A flattened bundle contains a single file with every reachable type inlined and no internal imports. Same public API, two very different published shapes emitted tree index.d.ts client.d.ts internal/cache internal/retry 147 files, internal paths published flattened bundle index.d.ts every reachable type inlined 1 file, no internal paths, faster to load

Step-by-Step Fix

1. Configure the extractor against the emitted tree

{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "<projectFolder>/dist/index.bundled.d.ts"
  },
  "apiReport": {
    "enabled": true,
    "reportFolder": "<projectFolder>/etc/"
  },
  "docModel": { "enabled": false },
  "compiler": { "tsconfigFilePath": "<projectFolder>/tsconfig.build.json" }
}
{
  "scripts": {
    "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && api-extractor run --local && mv dist/index.bundled.d.ts dist/index.d.ts"
  }
}
npm run build:types && grep -c 'from "\./' dist/index.d.ts || echo "0 internal imports"

Expected output: 0 internal imports.

2. Keep dependency types as imports

An inlined dependency type is a copy, and a copy is not structurally identical to the original for every purpose — most visibly with branded or nominal types. The default behaviour of leaving them as imports is correct:

// dist/index.d.ts — after bundling
import type { ZodSchema } from "zod";              // kept as an import: correct
interface RetryPolicy { attempts: number; }        // inlined from internal/retry: correct
export declare function createClient(o: ClientOptions): Client;

3. Commit the API report and review its diffs

api-extractor run --local
git diff --exit-code etc/my-library.api.md || {
  echo "public type surface changed — review before committing"; exit 1;
}
Warning: The API report file has changed:
+ export declare function createClient(options: ClientOptions, signal?: AbortSignal): Client;
- export declare function createClient(options: ClientOptions): Client;

That diff is the moment to make the semver decision. An added optional parameter is a minor; a changed return type or a narrowed parameter is a major. Without the report, the same change is invisible in review and the decision gets made by accident.

The API report in a pull request A source change regenerates the API report, whose diff appears in the pull request alongside the code, prompting an explicit semver decision before the change is merged. The type surface becomes something a human reviews source change a signature edited report regenerated etc/*.api.md updated diff in the PR visible to a reviewer semver decided Without the report the same change is invisible until a consumer's build breaks

4. Decide about declaration maps deliberately

   "compilerOptions": {
     "declaration": true,
-    "declarationMap": true,
   }

A flattened declaration has no one-to-one source correspondence, so a map emitted alongside it points at intermediate files that are not published. Either drop declarationMap when bundling, or keep per-file declarations and accept the larger tree — the two benefits genuinely conflict.

HAZARD PREVENTION

Symptom: The bundled declaration is emitted, and attw reports the package as untyped for one consumer mode.

Root cause: The bundling step produced a single .d.ts while the exports map still names a .d.cts for the require condition — a file the bundler never wrote.

Fix: Produce both extensions (run the roll-up twice, or copy with the alternative extension) and keep each condition pointing at the matching file. A single declaration shared by both conditions is the mismatch attw exists to catch.


Verification

# one declaration, self-contained
find dist -name '*.d.ts' | wc -l
grep -c 'from "\./' dist/index.d.ts || echo "no internal imports"

# it is still valid TypeScript on its own
npx tsc --noEmit --skipLibCheck false dist/index.d.ts && echo "declaration valid"

# and resolves for every consumer mode
npx attw --pack .

Expected: a small file count, no internal imports, a valid declaration, and a clean attw table.


Edge Cases / Gotchas

  • Ambient declarations can be dropped. Global augmentations in separate .d.ts files may not be reachable from the entry point and can be omitted from the roll-up; include them explicitly.
  • @internal members need stripInternal to disappear. Bundling inlines whatever it reaches, including members you consider private unless they are marked and stripping is enabled.
  • Multiple entry points need multiple roll-ups. A package with subpath exports must produce one bundled declaration per subpath, each with its own configuration.
  • Inlined dependency types break structural identity. A branded type copied out of a dependency is no longer the same type as the original, which produces confusing assignability errors for consumers.
  • The report is not a changelog. It records what changed in the type surface, not why; the two belong in the same pull request but serve different readers.

Whether to bundle at all comes down to the size of the declaration tree and how much you value navigation into source. Large trees benefit substantially; a package with a dozen declaration files gains little and loses go-to-definition for the trouble.


Frequently Asked Questions

Why bundle declarations at all?

Faster editor loading for consumers, no internal module paths in the published types, and a flattened output that can be diffed as an API report.

How does it decide what to include?

It follows every type reachable from the entry point’s exports, inlining as it goes, which is how the result becomes self-contained.

What happens to types from dependencies?

They stay as imports by default, which is correct — inlining would duplicate them and break structural compatibility with the original.

Does bundling break declaration maps?

In practice yes: the flattened output no longer corresponds to any single source file, so navigation lands on the bundle rather than your TypeScript.

Is an API report worth maintaining?

For any package with more than a handful of consumers, yes — it turns a change in the public type surface into a reviewable diff at the moment the semver decision should be made.



↑ Back to Declaration File Generation and Type Stripping