お疲れ。最近、TypeScriptの導入が進んでいる現場は多いけれど、既存のレガシーなコードベースや、あえてビルドステップを複雑にしたくないマイクロライブラリの構築などで、「プレーンなJavaScript(JSDoc)でどこまで型安全を担保できるか」という壁にぶぶつかっていないかい?
特に、CommonJSやES Modules(ESM)が入り交じるカオスな環境で、モジュール全体のエクスポートやインスタンスの型を正しくIDE(VSCodeなど)に認識させるのは、中級からシニアへステップアップする上で避けて通れない登竜門だ。
今回は、JSDocの `@module` と `@exports` を駆使して、JavaScriptのモジュール型定義を極限まで美しく、かつ実務で耐えうるレベルに落とし込む方法を伝授しよう。
—
なぜ、いま「JSDocによるモジュール型定義」なのか?
「型を書きたいならTypeScriptを使えばいいじゃないか」——その通り。だが、実務ではそう簡単にいかない場面もある。
例えば、BabelやTypeScriptのコンパイラを通さずに、ブラウザが直接解釈するスクリプトや、npmに公開する軽量なユーティリティライブラリを純粋なJSでメンテしたい時だ。
VSCodeなどのモダンなエディタは、裏側でTypeScriptの言語サービス(tsserver)を動かしている。つまり、JSDocを正しく書けば、TypeScriptを書いているのとほぼ同等の強力な型補完や静的解析の恩恵を、拡張子 `.js` のまま受けられるというわけだ。
しかし、ここで多くのエンジニアが躓く。
「あれ、モジュール全体をエクスポートしているファイルで、型補完が効かなくなったぞ?」と。
その原因の多くは、`@module` と `@exports` のスコープと紐付けのルールを誤っていることにある。
—
ブラウザとエディタの裏側の話
まず、JavaScriptエンジン(V8やSpiderMonkeyなど)がどう動いているかを思い出してほしい。ブラウザ自体はJSDocのコメントなんてただの「空白(Whitespace)」として読み飛ばす。実行時には1バイトの役にも立たない。
だが、エディタの裏で動くLSP(Language Server Protocol)は別だ。
`@module` タグが現れると、tsserverはそのファイル自体をひとつの「モジュール名前空間」として認識する。そして、それに続く `@exports` が、そのモジュールが外部に公開するインターフェース(API)の型を定義する。
この紐付けが曖昧だと、エディタは「一体どのオブジェクトがこのモジュールのエクスポートなんだ?」と混乱し、型が `any` に落ちてしまう。ここをビシッと明示するのがプロの技だ。
—
現場で使える!実践コード例
百聞は一見に如かず。CommonJSとESMの両方で、美しく型が効くモジュール定義のパターンを見ていこう。今回は実務でよくある「設定管理とAPIクライアントを内包したユーティリティモジュール」を例にする。
パターン1: ES Modules (ESM) 環境での `@module` と `@exports`
ESM環境(現代のモダンなフロントエンドやNode.js)では、`export default` や名前付きエクスポートを使うが、JSDocで高度なオブジェクト構造やクラスのインスタンスを定義する場合、`@module` と `@exports` を組み合わせるのが最も確実だ。
/
- @file ユーザー認証とセッションを管理するモジュール
- @module AuthManager
/
/
- @typedef {Object} User
- @property {string} id – ユーザーの一意なID
- @property {string} name – ユーザーの表示名
- @property {‘admin’|’user’|’guest’} role – 権限ロール
/
/
- @typedef {Object} AuthConfig
- @property {string} endpoint – APIのエンドポイント
- @property {number} [timeout=5000] – タイムアウト時間(ミリ秒)
/
/
- 認証を司るクラス
/
export class AuthManager {
/
- @param {AuthConfig} config – 初期化設定
/
constructor(config) {
/ @private /
this.endpoint = config.endpoint;
/ @private /
this.timeout = config.timeout || 5000;
/ @type {User|null} /
this.currentUser = null;
}
/
- ログイン処理を行う
- @param {string} username – ユーザー名
- @param {string} password – パスワード
- @returns {Promise
} ログイン成功後のユーザー情報
/
async login(username, password) {
// 実際の通信処理(ここではダミー)
this.currentUser = { id: ‘u_123’, name: username, role: ‘admin’ };
return this.currentUser;
}
}
// モジュール全体のデフォルトエクスポートに対して型を明示的に紐付ける
/
- @exports AuthManager
/
export default AuthManager;
この書き方のポイント
1. `@file` と `@module AuthManager`: このファイル自体がモジュールであることを宣言し、名前空間を定義している。
2. `@typedef` による型の独立: 複雑なオブジェクト形状(`User`, `AuthConfig`)は事前に定義しておき、JSDocの型パースを安定させる。
3. `@exports AuthManager`: デフォルトエクスポートされたクラスやオブジェクトが、このモジュールの主役であることをエディタに教える。これにより、別ファイルでインポートした際に完璧な補完が効くようになる。
—
パターン2: CommonJS 環境でのモジュール型定義
レガシーなビルドツールや、Node.jsのCJS環境でモジュール全体をごっそり差し替えるような設計(`module.exports = …`)をする場合、JSDocの真価が問われる。ここを適当に書くと、一発で型が崩壊する。
/
- @file データベース接続を模したモジュール(CommonJS版)
- @module DatabaseConnector
/
/
- @typedef {Object} QueryOptions
- @property {boolean} [cache=true] – キャッシュを利用するかどうか
- @property {number} [retries=3] – 失敗時のリトライ回数
/
/
- データベース操作オブジェクト
/
const DatabaseConnector = {
/
- 接続状態
- @type {boolean}
/
isConnected: false,
/
- データベースに接続する
- @param {string} connectionString – 接続文字列
- @returns {Promise
}
/
async connect(connectionString) {
// 接続処理のシミュレーション
this.isConnected = true;
console.log(`Connected to: ${connectionString}`);
},
/
- クエリを実行する
- @param {string} sql – SQL文
- @param {QueryOptions} [options] – クエリ実行オプション
- @returns {Promise
/
async query(sql, options) {
if (!this.isConnected) {
throw new Error(‘未接続です。先にconnectを呼んでください。’);
}
// ダミーデータを返す
return [{ id: 1, query: sql, cached: options?.cache ?? true }];
}
};
// CommonJSでのモジュール全体のエクスポートに対する型定義
module.exports = DatabaseConnector;
/
- @exports DatabaseConnector
/
—
シニアからの実践的なアドバイス(ハマりどころと対策)
1. ファイル名とモジュール名の不一致に注意せよ
`@module` の後ろに記述する名前は、ファイルパスや変数名と一致させておくのが無難だ。エディタの自動補完機能が迷子になるのを防げる。
2. 外部ファイルから型を参照する方法
別のファイルで定義した `@typedef` を使いたい場合は、インポート元を `import(‘./path/to/file’).TypeName` のようにJSDoc内で参照できる(通称:Import Types)。これを使いこなせると、JSだけでも大規模なアーキテクチャが構築できるようになる。
/
- @param {import(‘./AuthManager.js’).User} user
/
function handleUser(user) {
// 完璧な型補完が効く!
}
3. 「とりあえず `any`」の誘惑に負けるな
型定義が面倒になって `@type {any}` を逃げ道に使い始めると、JSの最大のメリットである「動的な柔軟性」を残したまま「型の安心感」を失うという、一番最悪な状態に陥る。複雑な型こそ、しっかり `@typedef` で構造化しよう。
—
おわりに
TypeScript全盛の時代にあえてプレーンなJavaScriptとJSDocを極めることは、言語仕様の本質(ランタイムの挙動と静的解析の境界線)を深く理解する上で最高のトレーニングだ。
チームメンバーから「JSなのに、なんでこんなに補完が効いてミスが防げるんだ!?」と驚かれたら、シニアアーキテクチャとしての勝ち誇りを感じていい瞬間だよ。
明日からのコードに、ぜひこの `@module` と `@exports` の美学を取り入れてみてほしい。

コメント