Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Bundling Declarations with API Extractor

tsc emits one declaration file per source file, so a library with 80 modules ships 80 .d.ts files that mirror its internal folder structure. Consumers' editors see every internal type, deep imports into declaration files become de facto API, and a refactor that moves a file can break someone's types. API Extractor, from Microsoft's Rush Stack, rolls those files into a single declaration per entry point, trims internal symbols, and produces an API report that turns every public API change into a reviewable diff. This guide sets it up for a package, explains its output, and wires it into CI.

What problem API Extractor solves

Three problems push teams towards declaration bundling:

  1. Leaking internals. Any type reachable from dist/**/*.d.ts can be imported by consumers with a deep path, and editors happily auto-import it. Once someone depends on it, moving it is a breaking change.
  2. Accidental API changes. A renamed parameter type or a widened return type in an exported function changes your public API. Without a report, nobody notices until a consumer's build fails.
  3. Resolution fragility. Hundreds of small declaration files with relative imports between them multiply the chances of an extension or path mistake under node16 resolution, as described in Fixing Types Not Found Under node16 Module Resolution.

The symptoms that usually start the conversation:

# A consumer's auto-import picks an internal path
import { InternalCache } from 'your-lib/dist/internal/cache';

# A patch release breaks a consumer's build
error TS2345: Argument of type 'Options' is not assignable to parameter of type 'ClientOptions'.
  Property 'retries' is missing in type 'Options' but required in type 'ClientOptions'.

How API Extractor works

API Extractor runs after tsc. It reads the declaration files tsc emitted, starting from your entry point's .d.ts, follows every exported symbol, and produces three artefacts:

The API Extractor pipeline tsc emits per-file declarations, API Extractor analyses the entry point, and produces a rolled-up declaration file, an API report and optional doc model. tsc --declaration per-file .d.ts in a temp folder api-extractor run walks exports from the entry .d.ts dist/index.d.ts one rolled-up declaration your-lib.api.md API report committed to git
API Extractor consumes tsc's output; it never replaces the compiler.
  • The .d.ts rollup — a single declaration file containing every exported symbol and the types they reference, with internal-only declarations either inlined privately or trimmed.
  • The API report (*.api.md) — a normalised Markdown listing of the public API. Committed to the repository, it changes whenever the public API changes, so reviewers see API changes explicitly.
  • The doc model (*.api.json) — structured data used by API Documenter to generate reference documentation.

Release tags in TSDoc comments control what survives trimming: @public, @beta, @alpha and @internal. With trimming enabled, you can emit separate rollups for public and beta consumers.

Setting it up

Install and initialise:

npm install -D @microsoft/api-extractor
npx api-extractor init        # writes api-extractor.json with every option commented

A minimal configuration for a single-entry package:

{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "mainEntryPointFilePath": "<projectFolder>/temp/types/index.d.ts",
  "bundledPackages": [],
  "apiReport": {
    "enabled": true,
    "reportFolder": "<projectFolder>/etc/"
  },
  "docModel": { "enabled": false },
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "<projectFolder>/dist/index.d.ts",
    "publicTrimmedFilePath": ""
  },
  "tsdocMetadata": { "enabled": false },
  "messages": {
    "extractorMessageReporting": {
      "ae-missing-release-tag": { "logLevel": "none" },
      "ae-forgotten-export": { "logLevel": "error", "addToApiReportFile": false }
    }
  }
}

Emit per-file declarations to a temporary folder, then roll them up:

{
  "scripts": {
    "build:js": "tsup src/index.ts --format esm,cjs",
    "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --declaration --outDir temp/types",
    "build:api": "api-extractor run --local",
    "build": "npm run build:js && npm run build:types && npm run build:api"
  }
}

--local updates the committed API report when it changes. In CI you run without --local, which makes any difference between the generated report and the committed one a build failure.

Files before and after the rollup A tree showing tsc's many per-file declarations in temp/types and API Extractor's single rolled-up declaration and API report. your-lib/ temp/types/ tsc output, not published index.d.ts entry point client/options.d.ts + 78 more files dist/index.d.ts rolled-up, published etc/your-lib.api.md API report, committed api-extractor.json configuration
Consumers see one declaration file; the per-file declarations never leave the build machine.

Reading the API report in code review

The report is the real value for teams. A pull request that changes a public type shows a diff like:

  export interface ClientOptions {
+     retries: number;
      timeout?: number;
  }

That line is a breaking change for anyone constructing ClientOptions — a new required property. Reviewers can ask for it to be optional or for a major version bump before merging. Pair this with your release tooling: in Automating Releases with Changesets, require a major changeset whenever the report shows removed or tightened members.

The ae-forgotten-export warning

The message you will see most often is:

Warning: src/client.ts:14:3 - (ae-forgotten-export) The symbol "RetryPolicy" needs to be exported by the entry point index.d.ts

It means a public API references a type that consumers cannot import by name. They can still use it structurally, but they cannot annotate their own variables with it. Either export the type from the entry point (usually right) or restructure so the public API does not mention it. Setting this message to error, as in the configuration above, keeps the public surface coherent.

Multiple entry points and dual formats

API Extractor handles one entry point per configuration. For a package with your-lib and your-lib/react, create one configuration per entry (for example api-extractor.react.json extending the base with "extends") and run each. For dual ESM/CommonJS packages, run the rollup once and copy the result to both .d.ts and .d.cts names if the declarations are identical — which they are unless your API differs by format. The dual layout is covered in Generating Dual CJS/ESM Type Definitions.

Lighter alternatives exist. rollup-plugin-dts and tsup's --dts bundling produce rolled-up declarations without the report, and tsup --experimental-dts uses API Extractor internally. Choose API Extractor itself when the API report and release-tag trimming matter to you; otherwise a bundler plugin is simpler.

Declaration output options Compares plain tsc output, bundler dts plugins, and API Extractor on file count, API report, release-tag trimming and setup effort. tsc only bundler dts plugin API Extractor Declaration files one per module one per entry one per entry API report for review no no yes @internal / @beta trimming stripInternal only no full release tags Setup effort none low moderate
API Extractor is the only option that adds an API report and release-tag trimming; bundler plugins are simpler if you only need a rollup.

Rolling out API reports across a monorepo

Adding API Extractor to one package is an afternoon's work; adding it to thirty is a process change, and it goes more smoothly in stages.

Start with the packages other teams consume most — the design system, the API client, shared utilities — because that is where accidental API changes cause the most damage. Generate their first reports with --local, commit them without any code changes, and announce that from now on the report diff is part of review. The first week usually surfaces a few ae-forgotten-export warnings for types that were always part of the effective API but never exported by name; exporting them is a small, safe improvement.

Next, move the configuration into a shared package so every library extends the same base: "extends": "@acme/api-extractor-config/base.json", with each package setting only mainEntryPointFilePath and projectFolder. Wire the check into the task runner as its own task ("api:check") that depends on build:types, so it is cached like any other task and runs only for packages whose declarations changed.

Finally, connect the report to versioning. A simple script in CI can classify the report diff: removed lines inside exported declarations mean a potential major, added optional members mean a minor, and documentation-only changes mean a patch. Comparing that classification with the changeset attached to the pull request catches the most common release mistake — shipping a breaking change as a minor — before it reaches consumers.

Limitations to plan around

API Extractor analyses declaration files, so anything tsc cannot express in .d.ts output is invisible to it. It does not support every TypeScript construct in rollups: some patterns involving declare global, module augmentation of other packages, or export * as ns re-exports need care or are reported as errors, and the tool's changelog is worth checking when you adopt newer syntax. It also expects a single entry point per run, which makes packages with dozens of subpath exports tedious to configure; those packages are often better served by a bundler's declaration plugin plus a lighter API diff such as a snapshot of attw output or a type-level test suite.

CI/CD integration

- run: npm ci
- run: npm run build:js && npm run build:types
- name: Check public API has not changed unexpectedly
  run: npx api-extractor run          # no --local: fails if etc/*.api.md differs
- run: npx @arethetypeswrong/cli --pack .

When the check fails, the job prints the report diff. The contributor runs npm run build:api locally, commits the updated report, and the change becomes visible in review.

Frequently Asked Questions

Does API Extractor replace tsc? No. It needs tsc's declaration output as input and adds analysis and bundling on top.

Why does my rollup still contain internal types? Types referenced by public declarations must be present for the rollup to be valid, so API Extractor includes them without exporting them. Mark truly internal members with @internal and enable a trimmed rollup to remove members consumers should not see.

Can I use it in a monorepo with many packages? Yes. Each package gets its own configuration, usually extending a shared base in a config package, and each package commits its own API report. Rush Stack's own monorepo works exactly this way.

What about packages that re-export types from dependencies? List those dependencies in bundledPackages only if you want their declarations inlined into your rollup. Normally leave the array empty so consumers resolve the dependency's own types.

Related

TypeScript Declaration Publishing