TypeScript Declaration Files: Two Strategies for DTS Generation
Prerequisites
- ›Completion of Articles 1–3
- ›Basic familiarity with TypeScript's compiler API (ts.createProgram, ts.Program)
- ›Understanding of .d.ts, .d.mts, and .d.cts file roles
- ›Awareness of Rollup's plugin API
TypeScript Declaration Files: Two Strategies for DTS Generation
esbuild is blindingly fast because it strips types rather than processing them. But library authors need .d.ts files — without them, consumers get no IntelliSense, no type checking, and no autocomplete. tsup provides two entirely different strategies for generating declarations, each with distinct trade-offs. This article dissects both.
Why DTS Generation Is Separate from JS Bundling
As we explored in Article 1, build() dispatches two independent task trees:
await Promise.all([dtsTask(), mainTasks()])
The JS pipeline uses esbuild; the DTS pipeline uses TypeScript-aware tools. These must run in parallel because DTS generation is often the slowest part of a tsup build — TypeScript's type checker is thorough but not fast. Running it concurrently with esbuild means the total build time is roughly max(esbuild_time, dts_time) rather than the sum.
flowchart LR
subgraph "Parallel Execution"
direction TB
M["mainTasks()<br/>esbuild → plugins → disk"]
D["dtsTask()<br/>TypeScript → Rollup/API Extractor → disk"]
end
B["build()"] --> M
B --> D
M -.->|"Promise.all"| DONE["Build complete"]
D -.-> DONE
The --dts path runs in a Worker thread for isolation. But Worker threads have a fundamental limitation: they communicate via structured cloning, and functions can't be cloned. This is why dtsTask() sanitizes the options before posting them:
worker.postMessage({
configName: item?.name,
options: {
...options,
injectStyle: typeof options.injectStyle === 'function'
? undefined : options.injectStyle,
banner: undefined,
footer: undefined,
esbuildPlugins: undefined,
esbuildOptions: undefined,
plugins: undefined,
treeshake: undefined,
onSuccess: undefined,
outExtension: undefined,
},
})
Every function-valued option is stripped to undefined. The DTS pipeline doesn't need them anyway — banners, footers, and esbuild plugins are irrelevant for type declarations.
Strategy 1 — --dts: Worker Thread + rollup-plugin-dts
The --dts path spawns a Node.js Worker thread running src/rollup.ts. The worker listens for a message containing the sanitized options, then builds a Rollup pipeline.
sequenceDiagram
participant Main as Main Thread (index.ts)
participant Worker as Worker Thread (rollup.ts)
participant Rollup as Rollup
participant DTS as rollup-plugin-dts
Main->>Worker: postMessage({ options })
Worker->>Worker: getRollupConfig(options)
Worker->>Rollup: rollup(inputConfig)
Rollup->>DTS: Process .ts files → .d.ts
DTS-->>Rollup: Bundled declarations
Rollup-->>Worker: bundle.write(outputConfig)
Worker->>Main: postMessage('success')
Main->>Main: resolve() / terminate worker
The Rollup configuration in getRollupConfig() assembles five plugins:
tsupCleanPlugin— removes existing.d.tsfiles from the output directory whencleanis enabledtsResolvePlugin— resolves types fromnode_moduleswhendts.resolveis setjsonPlugin— handles.jsonimports via@rollup/plugin-jsonignoreFiles— returns empty strings for non-code files (images, CSS, etc.) so Rollup doesn't choke on themdtsPlugin— the corerollup-plugin-dtsthat reads TypeScript source and emits bundled.d.tsfiles
The dtsPlugin configuration on lines 111-131 forces several compiler options:
dtsPlugin.default({
tsconfig: options.tsconfig,
compilerOptions: {
...compilerOptions,
declaration: true,
noEmit: false,
emitDeclarationOnly: true,
noEmitOnError: true,
checkJs: false,
declarationMap: false,
skipLibCheck: true,
target: ts.ScriptTarget.ESNext,
},
})
Setting target: ESNext ensures the parser can handle all modern syntax. skipLibCheck: true speeds things up by not type-checking .d.ts files from node_modules.
For watch mode, the worker uses Rollup's watch() API instead of rollup() + bundle.write(), subscribing to events and posting 'success' messages back to the main thread on each rebuild.
Also notable: when cjsInterop is enabled and the format is CJS, the output config includes FixDtsDefaultCjsExportsPlugin from the fix-dts-default-cjs-exports package, which ensures the declaration file properly reflects the module.exports = exports.default pattern applied by the cjsInterop plugin.
tsResolvePlugin: Finding Types in node_modules
The tsResolvePlugin is a custom Rollup plugin that locates .d.ts files in node_modules. By default, when building with --dts, all dependencies are externalized — their types appear as import statements in the output .d.ts. But when dts.resolve is enabled, the plugin inlines the types from specified packages.
flowchart TD
A["resolveId(source, importer)"] --> B{"Built-in module?"}
B -->|yes| C["return false (external)"]
B -->|no| D{"Matches resolveOnly?"}
D -->|no match & resolveOnly set| E["return null (skip)"]
D -->|matches| F{"Relative path?"}
F -->|yes| G["resolve with .d.ts/.ts extensions"]
F -->|no| H["Try node_modules resolution<br/>using pkg.types || pkg.typings"]
G --> I{"Found?"}
H --> I
I -->|yes| J["return resolved path"]
I -->|no| K["return false (external)"]
The resolution on lines 93-103 uses the resolve package with a custom packageFilter that tells it to look at the types or typings field in package.json instead of main:
packageFilter(pkg) {
pkg.main = pkg.types || pkg.typings
return pkg
},
paths: ['node_modules', 'node_modules/@types'],
It also searches node_modules/@types to catch DefinitelyTyped packages.
Cross-Thread Logging
When the DTS pipeline runs in a Worker thread, console.log output would appear on stderr without proper formatting. tsup solves this in src/log.ts:
log(label, type, ...data) {
const args = [makeLabel(name, label, type), ...data.map(/*...*/)]
switch (type) {
case 'error': {
if (!isMainThread) {
parentPort?.postMessage({ type: 'error', text: util.format(...args) })
return
}
return console.error(...args)
}
default:
if (silent) return
if (!isMainThread) {
parentPort?.postMessage({ type: 'log', text: util.format(...args) })
return
}
console.log(...args)
}
}
sequenceDiagram
participant W as Worker (rollup.ts)
participant L as Logger (log.ts)
participant PP as parentPort
participant M as Main Thread (index.ts)
W->>L: logger.success('dts', 'Build success')
L->>L: isMainThread? No
L->>PP: postMessage({ type: 'log', text: 'DTS Build success' })
PP-->>M: 'message' event
M->>M: console.log(data.text)
The isMainThread check from node:worker_threads determines whether to log directly or relay via parentPort.postMessage. The main thread's worker message handler on lines 240-254 replays these messages through the console, preserving the correct output order.
Tip: If you're debugging DTS build issues and logs seem missing, check for silent mode (
--silent). The worker thread respects thesilentflag just like the main thread, but errors always get through regardless of the silent setting.
Strategy 2 — --experimental-dts: tsc + API Extractor
The --experimental-dts path takes a fundamentally different approach. Instead of Rollup, it uses the TypeScript compiler API directly, followed by Microsoft's API Extractor to roll up declarations.
The runTypeScriptCompiler() function in src/tsc.ts drives the first phase. The emit() function creates a full TypeScript program with declaration-only compiler options:
const parsedTsconfig = ts.parseJsonConfigFileContent({
compilerOptions: {
...compilerOptions,
noEmit: false,
declaration: true,
declarationMap: true,
declarationDir, // .tsup/declaration/
emitDeclarationOnly: true,
},
}, ts.sys, /* ... */)
Declarations are emitted to .tsup/declaration/ — a staging directory, not the final output. The emitDtsFiles() function uses a custom WriteFileCallback to build a mapping from source file paths to output declaration paths.
After emission, getExports() walks the TypeScript program's root files, extracting every exported symbol using checker.getExportsOfModule(). Each export becomes an ExportDeclaration with its name, alias (for deduplication), source file, and destination file.
flowchart TD
A["runTypeScriptCompiler()"] --> B["Load tsconfig"]
B --> C["ts.createProgram()"]
C --> D["program.emit()<br/>with custom WriteFileCallback"]
D --> E[".tsup/declaration/*.d.ts<br/>(staging directory)"]
D --> F["fileMapping: source → output"]
F --> G["getExports(program, fileMapping)"]
G --> H["ExportDeclaration[]"]
H --> I["runDtsRollup()"]
I --> J["Format aggregation file"]
J --> K["API Extractor → single bundled .d.ts"]
K --> L["Per-entry distribution files"]
The second phase is handled by runDtsRollup() in src/api-extractor.ts. It writes an aggregation file that re-exports everything, runs API Extractor to produce a single rolled-up declaration, then generates per-entry output files.
Re-Export Formatting with exports.ts
The exports.ts module handles the formatting of re-export statements. Two functions serve different stages:
formatAggregationExports() generates the input file for API Extractor — it re-exports every symbol from the staging .d.ts files:
export { MyClass } from './staging/index.js';
export { helper as helper_alias_1 } from './staging/utils.js';
formatDistributionExports() generates the final per-entry output files — they re-export from the rolled-up declaration file.
The AliasPool class in tsc.ts handles name deduplication. When multiple source files export symbols with the same name, the pool assigns unique aliases:
class AliasPool {
private seen = new Set<string>()
assign(name: string): string {
let suffix = 0
let alias = name === 'default' ? 'default_alias' : name
while (this.seen.has(alias)) {
alias = `${name}_alias_${++suffix}`
}
this.seen.add(alias)
return alias
}
}
The default export gets special treatment — it's always renamed to default_alias since default is a reserved word that can't be used as a binding name in all contexts.
Comparing the Two Strategies: Tradeoffs and When to Use Each
| Aspect | --dts (Rollup) |
--experimental-dts (tsc + API Extractor) |
|---|---|---|
| Engine | rollup-plugin-dts | TypeScript compiler + @microsoft/api-extractor |
| Isolation | Worker thread | Main thread (same process) |
| Speed | Generally faster for simple projects | Can be faster for large projects (no Rollup overhead) |
| Correctness | May struggle with complex re-exports | Better at handling TypeScript's full type system |
| Dependencies | rollup, rollup-plugin-dts (bundled) |
@microsoft/api-extractor (optional install) |
| Watch mode | Full Rollup watch with incremental rebuilds | No watch support (runs full tsc each time) |
| Type resolution | dts.resolve option with custom resolver |
Follows TypeScript's native resolution |
| Ambient modules | Can be problematic | Handled correctly |
| Declaration merging | Limited support | Full support |
| Format-specific output | Per-format output extensions | Per-format output extensions |
The two strategies cannot be combined — using both simultaneously throws an error on lines 205-208:
if (options.dts && options.experimentalDts) {
throw new Error(
"You can't use both `dts` and `experimentalDts` at the same time",
)
}
Tip: Start with
--dts— it's the stable, well-tested path that handles the majority of use cases. Switch to--experimental-dtsonly if you encounter issues with complex type patterns like declaration merging, conditional types across re-exports, or ambient module augmentation. And remember to install@microsoft/api-extractoras a dev dependency before using it.
What's Next
Article 6, the final installment in this series, covers tsup's watch mode — how chokidar watches files, how esbuild's metafile tracks build dependencies for smart rebuilds, the debounce utility that coalesces rapid file changes, and the full onSuccess lifecycle with its cross-platform process management.