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つのプラグインが組み立てられます。
tsupCleanPlugin—cleanが有効なとき、出力ディレクトリから既存の.d.tsファイルを削除するtsResolvePlugin—dts.resolveが設定されているとき、node_modulesから型を解決するjsonPlugin—@rollup/plugin-jsonを使って.jsonのインポートを処理するignoreFiles— 画像や CSS などのコード以外のファイルに対して空文字列を返し、Rollup がエラーにならないようにする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: true は node_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.json の types または 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_threads の isMainThread フラグを使って、直接ログ出力するか 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.ts の runTypeScriptCompiler() が担います。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.ts の runDtsRollup() が担います。すべてを 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.ts の AliasPool クラスは名前の重複排除を担います。複数のソースファイルが同じ名前のシンボルをエクスポートしている場合、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 の型システム全体を正確に処理できる |
| 依存関係 | rollup、rollup-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 ライフサイクルの全体像を解説します。