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:
- Leaking internals. Any type reachable from
dist/**/*.d.tscan 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. - 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.
- Resolution fragility. Hundreds of small declaration files with relative imports between them multiply the chances of an extension or path mistake under
node16resolution, 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
.d.tsrollup — 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.
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.
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 covers generating and laying out declarations.
- Fixing tsup Output Missing .d.ts Type Declarations handles the most common bundler-side declaration problem.
- Publishing Declaration Maps for Go-to-Definition explains the trade-off between rollups and source navigation.
- Semantic Versioning and Release Automation connects API changes to version bumps.