Read OSS

TypeScript 型定義ファイル:DTS 生成の2つの戦略

上級

前提知識

  • 記事 1〜3 を読了していること
  • TypeScript コンパイラ API の基礎知識(ts.createProgram、ts.Program)
  • .d.ts、.d.mts、.d.cts ファイルの役割への理解
  • Rollup のプラグイン API に関する基礎知識

TypeScript 型定義ファイル:DTS 生成の2つの戦略

esbuild が圧倒的に速いのは、型を処理するのではなく取り除くからです。しかしライブラリ開発者にとって、.d.ts ファイルは不可欠です。これがなければ、利用者は IntelliSense も型チェックもオートコンプリートも使えません。tsup は型定義ファイルを生成するために、それぞれトレードオフの異なる2つの戦略を用意しています。本記事ではその両方を詳しく掘り下げます。

なぜ DTS 生成は JS バンドルと分離しているのか

記事1で見たように、build() は独立した2つのタスクツリーを並行して実行します。

await Promise.all([dtsTask(), mainTasks()])

JS パイプラインは esbuild を使い、DTS パイプラインは TypeScript を認識するツールを使います。この2つを並行して走らせるのは、DTS 生成が tsup のビルドの中で最も時間のかかる処理になることが多いからです。TypeScript の型チェッカーは正確ですが、決して速くはありません。esbuild と同時に実行することで、全体のビルド時間は esbuild_time + dts_time の合計ではなく、max(esbuild_time, dts_time) に近づきます。

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

--dts パスは分離のために Worker スレッド上で動きますが、Worker スレッドには根本的な制約があります。通信に structured cloning を使うため、関数を送信できないのです。そのため dtsTask() はメッセージを送る前にオプションを sanitize します。

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,
  },
})

関数を値として持つオプションはすべて undefined に置き換えられます。バナー、フッター、esbuild プラグインは型定義ファイルの生成には不要なので、DTS パイプラインがそれらを必要とすることはありません。

戦略1 — --dts:Worker スレッド + rollup-plugin-dts

--dts パスは、src/rollup.ts を実行する Node.js Worker スレッドを起動します。Worker は sanitize されたオプションを含むメッセージを受け取り、Rollup パイプラインを組み立てます。

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

getRollupConfig() では、次の5つのプラグインが組み立てられます。

  1. tsupCleanPluginclean が有効なとき、出力ディレクトリから既存の .d.ts ファイルを削除する
  2. tsResolvePlugindts.resolve が設定されているとき、node_modules から型を解決する
  3. jsonPlugin@rollup/plugin-json を使って .json のインポートを処理する
  4. ignoreFiles — 画像や CSS などのコード以外のファイルに対して空文字列を返し、Rollup がエラーにならないようにする
  5. dtsPlugin — TypeScript ソースを読み込んでバンドル済みの .d.ts を出力する、rollup-plugin-dts のコア処理

111〜131行目dtsPlugin の設定では、いくつかのコンパイラオプションが強制的に指定されます。

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,
  },
})

target: ESNext を指定することで、パーサーがあらゆるモダン構文を扱えるようになります。skipLibCheck: truenode_modules にある .d.ts ファイルの型チェックをスキップし、処理を高速化します。

watch モードでは、Worker は rollup() + bundle.write() の代わりに Rollup の watch() API を使います。イベントを購読し、リビルドのたびに 'success' メッセージをメインスレッドに送り返します。

なお、cjsInterop が有効かつフォーマットが CJS の場合、出力設定に fix-dts-default-cjs-exports パッケージの FixDtsDefaultCjsExportsPlugin が含まれます。これは、cjsInterop プラグインが適用する module.exports = exports.default パターンを型定義ファイルに正しく反映させるためのものです。

tsResolvePlugin:node_modules から型を探す

tsResolvePlugin は、node_modules 内の .d.ts ファイルを探し出すカスタム Rollup プラグインです。デフォルトでは --dts でビルドするとすべての依存関係が外部化され、出力される .d.ts には import 文としてのみ現れます。しかし dts.resolve を有効にすると、指定したパッケージの型定義がインライン展開されます。

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)"]

93〜103行目の解決処理では、resolve パッケージに packageFilter を渡して package.jsontypes または typings フィールドを参照させています。

packageFilter(pkg) {
  pkg.main = pkg.types || pkg.typings
  return pkg
},
paths: ['node_modules', 'node_modules/@types'],

DefinitelyTyped のパッケージも拾えるよう、node_modules/@types も検索対象に含まれています。

スレッドをまたいだログ出力

DTS パイプラインが Worker スレッドで動いている場合、console.log の出力は適切なフォーマットなしに stderr に流れてしまいます。tsup はこれを 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)

node:worker_threadsisMainThread フラグを使って、直接ログ出力するか parentPort.postMessage でメインスレッドに中継するかを切り替えています。メインスレッド側では240〜254行目のメッセージハンドラーがこれらのメッセージをコンソールに再出力し、正しい順序で表示されるようにしています。

ヒント: DTS ビルドのログが消えていると感じたら、まず silent モード(--silent)を確認しましょう。Worker スレッドもメインスレッドと同様に silent フラグを尊重しますが、エラーだけは silent の設定に関わらず必ず出力されます。

戦略2 — --experimental-dts:tsc + API Extractor

--experimental-dts パスはまったく異なるアプローチを取ります。Rollup の代わりに TypeScript コンパイラ API を直接使い、その後に Microsoft の API Extractor で型定義をまとめます。

最初のフェーズは src/tsc.tsrunTypeScriptCompiler() が担います。emit() 関数は、型定義出力専用のコンパイラオプションでフルの TypeScript プログラムを作成します。

const parsedTsconfig = ts.parseJsonConfigFileContent({
  compilerOptions: {
    ...compilerOptions,
    noEmit: false,
    declaration: true,
    declarationMap: true,
    declarationDir,  // .tsup/declaration/
    emitDeclarationOnly: true,
  },
}, ts.sys, /* ... */)

型定義ファイルはまず .tsup/declaration/ というステージングディレクトリに出力され、最終的な出力先には直接書き込まれません。emitDtsFiles() 関数はカスタムの WriteFileCallback を使い、ソースファイルのパスと出力される型定義ファイルのパスの対応関係を構築します。

出力が終わると、getExports() が TypeScript プログラムのルートファイルを走査し、checker.getExportsOfModule() を使ってすべてのエクスポートシンボルを取り出します。各エクスポートは名前、エイリアス(重複排除のため)、ソースファイル、出力先ファイルを持つ ExportDeclaration になります。

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"]

第2フェーズは src/api-extractor.tsrunDtsRollup() が担います。すべてを re-export する集約ファイルを書き出し、API Extractor でひとつにまとめた型定義を生成したあと、エントリーごとの出力ファイルを作成します。

exports.ts による re-export フォーマット

exports.ts モジュールは re-export 文の整形を担っています。2つの関数がそれぞれ異なるフェーズで使われます。

formatAggregationExports() は API Extractor への入力ファイルを生成します。ステージングにある .d.ts からすべてのシンボルを re-export します。

export { MyClass } from './staging/index.js';
export { helper as helper_alias_1 } from './staging/utils.js';

formatDistributionExports() は最終的なエントリーごとの出力ファイルを生成します。まとめられた型定義ファイルから re-export する形になります。

tsc.tsAliasPool クラスは名前の重複排除を担います。複数のソースファイルが同じ名前のシンボルをエクスポートしている場合、AliasPool が一意のエイリアスを割り当てます。

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
  }
}

default エクスポートは特別扱いされ、常に default_alias にリネームされます。default はすべての文脈でバインディング名として使えない予約語だからです。

2つの戦略の比較:トレードオフと使い分け

観点 --dts(Rollup) --experimental-dts(tsc + API Extractor)
エンジン rollup-plugin-dts TypeScript コンパイラ + @microsoft/api-extractor
分離 Worker スレッド メインスレッド(同一プロセス)
速度 シンプルなプロジェクトでは概ね速い 大規模プロジェクトでは速い場合も(Rollup のオーバーヘッドなし)
正確性 複雑な re-export で問題が出ることがある TypeScript の型システム全体を正確に処理できる
依存関係 rolluprollup-plugin-dts(同梱) @microsoft/api-extractor(別途インストールが必要)
watch モード インクリメンタルリビルドを含む Rollup watch 非対応(毎回フル tsc を実行)
型の解決 カスタムリゾルバーによる dts.resolve オプション TypeScript ネイティブの解決に従う
ambient モジュール 問題が起きることがある 正しく処理される
declaration merging サポートが限定的 完全にサポート
フォーマット別出力 フォーマットごとの出力拡張子 フォーマットごとの出力拡張子

この2つの戦略は併用できません。同時に指定すると205〜208行目でエラーが発生します。

if (options.dts && options.experimentalDts) {
  throw new Error(
    "You can't use both `dts` and `experimentalDts` at the same time",
  )
}

ヒント: まずは --dts から始めましょう。安定していて十分にテストされており、大多数のユースケースに対応しています。declaration merging、re-export をまたいだ conditional types、ambient モジュールの augmentation といった複雑な型パターンで問題が起きた場合にのみ、--experimental-dts への切り替えを検討してください。使用前に @microsoft/api-extractor を dev dependency としてインストールすることも忘れずに。

次回予告

シリーズ最終回となる記事6では、tsup の watch モードを取り上げます。chokidar によるファイル監視の仕組み、esbuild の metafile を使ったスマートなリビルドのための依存関係追跡、急速なファイル変更をまとめるデバウンスユーティリティ、そしてクロスプラットフォームのプロセス管理を含む onSuccess ライフサイクルの全体像を解説します。