こんにちは。テックリードの私だ。
フロントエンドとバックエンドの境界線で、今日も「APIの型定義のズレ」による型エラーや実行時例外に頭を悩ませていないか? バックエンドの開発者がDBのスキーマやSwagger(OpenAPI)を変更した瞬間、フロントエンド側で `npm run generate` を手動で叩き、生成されたファイルをGitでコミットする――そんな前時代的なワークフローは、今日この瞬間で終わりにする。
今回は、Webpackの心臓部である「Loader Context」を完全にハックし、物理的なソースコードファイルが存在しない状態から、外部JSONやデータベーススキーマを元にTypeScriptの型定義をビルド時に動的生成し、バンドルパイプラインに直接流し込むという、極限まで洗練された自動化アーキテクチャを伝授する。
単なる「お勉強」ではない。実際のプロダクション環境で即座に導入でき、チームの開発スピードを文字通り桁違いに引き上げるプロの実践テクニックを解説しよう。
—
1. なぜ「手動コード生成」は破綻するのか?
多くのチームでは、API定義から型を生成するために `openapi-generator` などのCLIツールをCI/CDやnpmスクリプトに組み込んでいる。しかし、これには以下の致命的な欠点がある。
- 開発体験(DX)の断絶: バックエンドの変更を検知して手動(あるいは別プロセスで)コマンドを実行し忘れると、ローカル環境で古い型のままコードを書き続け、PRを出した後にCIで初めて破綻に気づく。
- ファイルシステムの汚染: リポジトリ内に自動生成された `.d.ts` ファイルが乱立し、Gitの差分がノイズまみれになる。
Webpackのビルドプロセスに「動的型生成Loader」を組み込むと、「コンパイル(バンドル)が走る瞬間、メモリ上で最新のスキーマから型が生成され、TypeScriptの型チェッカーに直接読み込まれる」という理想郷が完成する。ファイルシステムを一切汚染せず、常に最新の型安全が保証されるのだ。
—
2. Webpack Loader Contextの深淵:`this.emitFile` と `this.addDependency`
WebpackのLoaderの本質は、ただの文字列変換関数ではない。第1引数(または `this` コンテキスト)を通じて、Webpackの内部ビルダーと深く対話する「強力なエージェント」である。
今回は、以下の2つのLoader Context APIを駆使する。
1. `this.addDependency(filePath)`:
Webpackに対し、「このファイルも依存関係に含めろ」と指示する。外部のJSONやDBスキーマファイルに変更があった際、Webpackのファイル監視(Watchモード)がそれを検知し、自動的にHMR(Hot Module Replacement)や再ビルドをトリガーする。
2. `this.emitFile(name, content)`:
バンドル出力ディレクトリ(あるいはいわゆる仮想ファイルシステム上)に、新たなアセットを動的に生成・出力する。これにより、ディスク上に存在しない `.d.ts` をWebpackの管理下に置くことができる。
—
3. 実装:動的型生成カスタムLoaderの構築
それでは、実際に動くコードを見ていこう。
今回は、プロジェクトルートにある `schema.json`(あるいはバックエンドから取得したJSON定義)を読み込み、対応するTypeScriptのインターフェースコードをメモリ上で動的に生成して型定義としてインジェクトするカスタムLoaderを作成する。
プロジェクト構成
.
├── webpack.config.js
├── loaders/
│ └── dynamic-type-loader.js # 今回作成する神Loader
├── src/
│ ├── index.ts
│ └── types/
│ └── generated.d.ts # emitFileによって仮想生成されるファイル
└── schema.json # バックエンドのスキーマ定義(真実のソース)
1. スキーマファイルの例 (`schema.json`)
{
“$schema”: “http://json-schema.org/draft-07/schema#”,
“title”: “UserProfile”,
“type”: “object”,
“properties”: {
“id”: { “type”: “string” },
“name”: { “type”: “string” },
“age”: { “type”: “number” },
“roles”: {
“type”: “array”,
“items”: { “type”: “string” }
}
},
“required”: [“id”, “name”]
}
2. カスタムLoaderの実装 (`loaders/dynamic-type-loader.js`)
このLoaderは、JSONスキーマをパースし、TypeScriptの `interface` 文字列に変換した上で、`this.emitFile` を使ってビルド成果物として吐き出す。
const path = require(‘path’);
/
- JSONスキーマからTypeScriptのインターフェース文字列を動的に生成する関数
- @param {Object} schema – パースされたJSONスキーマ
- @returns {string} 生成されたTypeScriptのコード
/
function jsonSchemaToTypeScript(schema) {
const title = schema.title || ‘GeneratedType’;
const properties = schema.properties || {};
const requiredFields = schema.required || [];
let tsCode = `// — Auto-generated by Webpack Dynamic Type Loader —\n`;
tsCode += `// DO NOT EDIT THIS FILE DIRECTLY.\n\n`;
tsCode += `export interface ${title} {\n`;
for (const [key, prop] of Object.entries(properties)) {
// 必須フィールドでない場合はオプショナルにする
const isRequired = requiredFields.includes(key);
const optionalMarker = isRequired ? ” : ‘?’;
let tsType = ‘any’;
if (prop.type === ‘string’) tsType = ‘string’;
if (prop.type === ‘number’) tsType = ‘number’;
if (prop.type === ‘boolean’) tsType = ‘boolean’;
if (prop.type === ‘array’) {
const itemType = prop.items && prop.items.type ? prop.items.type : ‘any’;
tsType = `${itemType}[]`;
}
tsCode += ` ${key}${optionalMarker}: ${tsType};\n`;
}
tsCode += `}\n`;
return tsCode;
}
module.exports = function(source) {
// 非同期処理としてWebpackにハンドリングさせるため async を取得
const callback = this.async();
try {
// 1. 入力されたJSON(スキーマ)をパース
const schema = JSON.parse(source);
// 2. 外部スキーマファイルの変更をWebpackのWatchモードに監視させる
// これにより、schema.jsonを書き換えた瞬間にビルドが走るようになる
const schemaPath = path.resolve(__dirname, ‘../schema.json’);
this.addDependency(schemaPath);
// 3. スキーマからTypeScriptの型定義コードを動的生成
const tsDefinition = jsonSchemaToTypeScript(schema);
// 4. this.emitFile を使って、仮想的な型定義ファイルをビルド出力に含める
// 出力先は output.path からの相対パスとなる
this.emitFile(‘types/generated.d.ts’, tsDefinition);
// 5. Loader自体の戻り値として、元のソースコード(またはトランスパイル後のコード)を返す
// 今回はJSONをそのままモジュールとして読み込めるようにエクスポートするコードを返す
const result = `export default ${JSON.stringify(schema)};`;
callback(null, result);
} catch (err) {
callback(err);
}
};
3. Webpack設定ファイル (`webpack.config.js`)
作成したLoaderをWebpackに組み込む。
const path = require(‘path’);
module.exports = {
mode: ‘development’,
entry: ‘./src/index.ts’,
output: {
filename: ‘bundle.js’,
path: path.resolve(__dirname, ‘dist’),
},
resolve: {
extensions: [‘.ts’, ‘.js’, ‘.json’],
},
module: {
rules: [
{
// schema.json を検知したら、先ほど作成したカスタムLoaderを適用する
test: /schema\.json$/,
use: [
{
loader: path.resolve(__dirname, ‘loaders/dynamic-type-loader.js’),
},
],
},
{
test: /\.ts$/,
use: ‘ts-loader’,
exclude: /node_modules/,
},
],
},
};
—
4. プロの現場で役立つ実践テクニック & 隠し設定
このアーキテクチャをチーム導入する際、さらに開発体験を爆発的に高めるための「プロの知見」をいくつか共有しよう。
1. `tsconfig.json` との連携(仮想ファイルの型解決)
`this.emitFile` で生成された `.d.ts` は、ビルド時に `dist/types/generated.d.ts` として出力される。TypeScriptのコンパイラ(`ts-loader`)がこの型を正しく認識できるように、`tsconfig.json` の `typeRoots` や `paths` を適切に設定しておくことが重要だ。
{
“compilerOptions”: {
“target”: “es2022”,
“module”: “esnext”,
“moduleResolution”: “node”,
“strict”: true,
“baseUrl”: “.”,
“paths”: {
“@generated/”: [“dist/types/”]
}
}
}
これにより、ソースコード側から `import { UserProfile } from ‘@generated/generated.d.ts’;` と美しくインポートしつつ、ビルド時に自動生成された最新の恩恵を受けることができる。
2. 開発効率を極限まで高める神キーボードショートカット (VSCode前提)
テックリードとしてチーム全員に推奨しているVSCodeのショートカット設定だ。
スキーマ変更 → 即座の型反映を確認するため、「ビルドの再起動(Restart TS Server)」 と 「Webpack Watchのトリガー」 をワンストップで行えるようにする。
- `Ctrl + Shift + P` (Macは `Cmd + Shift + P`) から “TypeScript: Restart TS Server” を実行するショートカットに `F12` などの押しやすいキーを割り当てる。
これにより、Loaderによって生成された型定義の変更が即座にVSCodeの言語サーバー(tsserver)に再読み込みされ、エディタ上の型エラーがリアルタイムで更新される。
—
5. まとめ:ツールを「使われる側」から「使いこなす側」へ
今回紹介したWebpackの `Loader Context` を活用した動的コード生成は、単なるテクニックの範疇を超え、「フロントエンドとバックエンドの契約(Contract)のズレ」という永年の課題を根絶する強力なアーキテクチャパターンである。
ファイルシステムを汚さず、ビルドパイプラインのメモリ上ですべてを完結させ、`this.addDependency` によってリアルタイムな変更検知を実現する。この仕組みをプロジェクトに導入した瞬間から、あなたのチームの開発スピードは一段上のステージへとシフトするはずだ。
型定義の手動更新という不毛な作業からエンジニアを解放し、真に価値のあるビジネスロジックの実装に集中できる環境を、あなたの手で構築してほしい。