Read OSS

TypeScript Declaration Files: Two Strategies for DTS Generation

Advanced

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:

  1. tsupCleanPlugin — removes existing .d.ts files from the output directory when clean is enabled
  2. tsResolvePlugin — resolves types from node_modules when dts.resolve is set
  3. jsonPlugin — handles .json imports via @rollup/plugin-json
  4. ignoreFiles — returns empty strings for non-code files (images, CSS, etc.) so Rollup doesn't choke on them
  5. dtsPlugin — the core rollup-plugin-dts that reads TypeScript source and emits bundled .d.ts files

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 the silent flag 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-dts only 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-extractor as 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.