API Extractorを用いて外部依存の型定義を単一ファイルにバンドルする

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

## はじめに

ViteやParcelなどのビルドツールでライブラリをビルドする際、外部依存をバンドルに含めたいことがあります。
しかし、これらのビルドツールで型定義を出力する場合、外部依存の型定義はバンドルに含まれません。その結果、別プロジェクトで使用する際に依存関係を解決できず、正しい型を参照できない問題が発生します。

この問題を解決するために[API Extractor](https://github.com/microsoft/rushstack/tree/main/apps/api-extractor)を用いて、外部依存の型定義を単一の型定義ファイルにバンドルする方法を紹介します。

実際の実装はこちらの[サンプルリポジトリ](https://github.com/suke/api-extractor-sample)をご参照ください。

## サンプルのプロジェクト構成

今回はサンプルとしてpnpm workspacesを用いたモノレポのプロジェクトを想定し、ビルドツールはViteを使用します。
また、tscで型チェック + 型定義の出力を行います。

以下は解説に必要な最低限のプロジェクト構成です。

```
.
├── package.json
├── pnpm-workspace.yaml
└── packages/
    ├── package1/
    │   └── package.json
    │   └── vite.config.ts
    │   └── src/index.ts
    ├── package2/
    │   └── package.json
    │   └── src/index.ts
    └── package3/
    │   └── package.json
    │   └── src/index.ts
```

package1は外部に公開され、package2とpackage3に依存しています。
以下にpackage1のpackage.jsonと各パッケージのindex.tsの内容を示します。

**package1/package.json**
```json
{
  "name": "package1",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsc && vite build",
  },
  "devDependencies": {
    "@types/node": "^20.12.12",
    "typescript": "^5.2.2",
    "vite": "^5.2.0"
  },
  "dependencies": {
    "package2": "workspace:*",
    "package3": "workspace:*"
  }
}
```

**package1/src/index.ts**
```typescript
export type { Package2 } from 'package2'
export type { Package3 } from 'package3'

export type Package1 = {}
```

**package2/src/index.ts**
```typescript
export type Package2 = {}
```
**package3/src/index.ts**
```typescript
export type Package3 = {}
```

## Viteなどで外部依存をバンドルに含めて型定義を出力する際の問題点

通常、ライブラリを作成する際には依存関係をバンドルから除外します。
例えば、Vite の設定ファイルを以下のように記述します。

**package1/vite.config.ts**
```typescript
import { resolve } from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist'),
    emptyOutDir: false,
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'package1',
      fileName: 'index'
    },
    rollupOptions: {
      external: ['package2', 'package3'],
    },
  },
})
```

この設定ではpackage2とpackage3を外部依存として指定し、バンドルに含めません。
ビルド結果は以下の通りです。

**package1/dist/index.d.ts**
```typescript
export type { Package2 } from 'package2'
export type { Package3 } from 'package3'

export type Package1 = {}
```

package2とpackage3はバンドルに含めていないため、想定通りの出力です。

次に外部依存をビルドに含めて型定義を出力してみます。
vite.config.tsを以下のように変更してビルドします。

**package1/vite.config.ts**
```typescript
import { resolve } from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist'),
    emptyOutDir: false,
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'package1',
      fileName: 'index'
    }
  },
})
```

ビルド結果は以下の通りです。

**package1/dist/index.d.ts**
```typescript
export type { Package2 } from 'package2'
export type { Package3 } from 'package3'

export type Package1 = {}
```

外部依存をビルドに含めて型定義を出力しても型定義の外部依存は解消されません。
そのため、ライブラリをビルドして別プロジェクトで使用する際に、型を参照できずanyになってしまいます。

これらの問題を解決するためにAPI Extractorを使用します。

## API Extractorとは

API ExtractorはRush Stackコミュニティで開発されているTypeScriptの解析ツールです。
以下の3つの機能を提供しています。

- APIレポート: プロジェクトのメインエントリーポイントからのすべてのエクスポートをトレースするレポートを生成する
- .d.tsロールアップ: すべてのTypeScript宣言を単一の.d.tsファイルに統合する
- APIドキュメント: 型の署名とドキュメントコメントを含むJSONファイルを作成する

今回は各パッケージの.d.tsを結合する.d.tsロールアップの機能を使用します。

## API Extractorの導入

package1に@microsoft/api-extractorをインストールします

```bash
pnpm add -D @microsoft/api-extractor
```

### api-extractor.jsonを作成する

API Extractorの設定を記述するJSONを作成します。
.d.tsの結合には以下のプロパティを使用します。

- mainEntryPointFilePath
  - API Extractorが解析するエントリーポイントのファイルパスを指定する
- bundledPackages
  - API Extractorがバンドルに含める追加のパッケージを指定する
- dtsRollup.untrimmedFilePath
  - Doc commentのリリースタグを無視して出力されるファイルのパス


dtsRollupはDoc commentのリリースタグに応じて複数の.d.tsを出力できます。
今回はリリースタグを使用しないため、untrimmedFilePathに元ファイルを上書きする形で出力します。


**package1/api-extractor.json**
```json
{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "mainEntryPointFilePath": "./dist/index.d.ts",
  "bundledPackages": ["package2", "package3"],
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "./dist/index.d.ts"
  }
}
```

### ビルドスクリプトを変更と実行

既存のビルドプロセスの最後にAPI Extractorで型定義を結合するプロセスを追加します。

**package1/package.json**
```diff_json
{
  "scripts": {
-    "build": "tsc && vite build",
+    "build": "tsc && vite build && api-extractor run --local",
  },
}
```

ビルドコマンド実行後に出力されたindex.d.tsは以下の通りです。

**package1/dist/index.d.ts**

```typescript
export declare type Package1 = {};

export declare type Package2 = {};

export declare type Package3 = {};

export { }
```

これで出力されたindex.d.tsに外部依存が含まれなくなり、追加の依存無しで外部パッケージから利用できるようになりました。

## まとめ

API Extractorを用いて外部依存の型定義をバンドルに含める方法を紹介しました。
紹介した内容は頻繁に発生する問題ではありませんが、同じ問題に遭遇した際にはこの記事が参考になれば幸いです。

## 参考
- https://api-extractor.com
- https://github.com/parcel-bundler/parcel/issues/8267
- https://github.com/parcel-bundler/parcel/issues/4243

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。