T-CREATOR

Node.js プロジェクト初期化テンプレ:ESM 前提の `package.json` 設計と `exports` ルール

Node.js プロジェクト初期化テンプレ:ESM 前提の `package.json` 設計と `exports` ルール

Node.js でモダンなプロジェクトを始める際、最初の一歩となる package.json の設計は非常に重要です。特に ESM(ECMAScript Modules)を前提とした設計では、従来の CommonJS とは異なる設定が必要になります。 この記事では、ESM を標準とする Node.js プロジェクトの初期化テンプレートと、exports フィールドの適切な設計方法を詳しく解説していきますね。

背景

Node.js におけるモジュールシステムの変遷

Node.js は長年 CommonJS をモジュールシステムとして採用してきました。しかし、JavaScript の標準仕様として ECMAScript Modules(ESM)が策定されたことで、Node.js も ESM をサポートするようになりました。

現在の Node.js では、以下の 2 つのモジュールシステムが共存しています。

#モジュール形式拡張子import/exportrequire
1CommonJS.js, .cjs★★★
2ESM.js, .mjs★★★

ESM はブラウザでも Node.js でも同じ構文で動作するため、フロントエンドとバックエンドでコードを共有しやすくなります。また、静的解析が可能なため、Tree Shaking などの最適化も効果的に行えるのです。

以下の図は、Node.js におけるモジュールシステムの判定フローを示しています。

mermaidflowchart TD
  start["ファイル読み込み"] --> check_ext{"拡張子<br/>チェック"}
  check_ext -->|.mjs| esm["ESM として<br/>解釈"]
  check_ext -->|.cjs| cjs["CommonJS として<br/>解釈"]
  check_ext -->|.js| check_type{"package.json の<br/>type フィールド"}
  check_type -->|"type: module"| esm
  check_type -->|"type: commonjs"<br/>または未指定| cjs

この図から分かるように、.js ファイルの扱いは package.jsontype フィールドによって決まります。ESM を標準にするには、この設定が重要になってくるのですね。

なぜ ESM を標準にすべきなのか

モダンな開発環境では、以下の理由から ESM を標準とすることが推奨されます。

  • 標準仕様への準拠: ECMAScript の公式仕様として策定されている
  • ブラウザとの互換性: 同じモジュール構文をブラウザでも使える
  • 静的解析の容易さ: ビルドツールによる最適化がしやすい
  • 非同期読み込み: Top-level await が使える
  • 将来性: Node.js の開発方針も ESM を中心に進んでいる

このような背景から、新規プロジェクトでは ESM を前提とした設計を採用することが、長期的なメンテナンス性の向上につながります。

課題

ESM 移行における混乱ポイント

ESM を前提としたプロジェクトを始める際、多くの開発者が以下のような課題に直面します。

課題 1: package.jsontype フィールド設定ミス

type フィールドを設定し忘れると、.js ファイルが CommonJS として解釈されてしまいます。

エラーコード: SyntaxError

typescript// エラーメッセージ例
SyntaxError: Cannot use import statement outside a module

発生条件: package.json"type": "module" を設定せずに、.js ファイルで import 構文を使用した場合

課題 2: exports フィールドの理解不足

exports フィールドは、パッケージの公開インターフェースを定義する重要な設定ですが、設定が複雑で理解しにくいという問題があります。

以下の図は、exports フィールドがどのようにモジュール解決に影響するかを示しています。

mermaidflowchart LR
  user["開発者"] -->|"import 'pkg'"| resolver["Node.js<br/>モジュール解決"]
  resolver --> check_exports{"exports<br/>フィールド<br/>存在?"}
  check_exports -->|あり| exports_path["exports で<br/>定義されたパス"]
  check_exports -->|なし| main_field["main フィールド<br/>または<br/>index.js"]
  exports_path --> file1["実際の<br/>ファイル"]
  main_field --> file2["実際の<br/>ファイル"]

exports フィールドが存在する場合、main フィールドは無視され、exports の定義が優先されます。この挙動を理解していないと、意図しないファイルが読み込まれる可能性があるのです。

課題 3: 拡張子の明示が必要

ESM では、相対パスでモジュールをインポートする際に拡張子の明示が必須です。

エラーコード: Error [ERR_MODULE_NOT_FOUND]

typescript// エラーメッセージ例
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/path/to/file' imported from /path/to/index.js
Did you mean to import ../file.js?

発生条件: ESM モードで拡張子を省略してインポートした場合

解決方法:

  1. インポート文に .js 拡張子を明示的に追加する
  2. TypeScript を使用している場合は、tsconfig.jsonmoduleResolution を適切に設定する
  3. エディタの自動インポート機能が拡張子を含めるように設定する

課題 4: TypeScript との統合

TypeScript で開発する場合、.ts ファイルをコンパイルした結果が .js になるため、インポート文での拡張子の扱いに注意が必要です。

#記述方法TypeScript実行時
1import '.​/​file'★★★
2import '.​/​file.ts'
3import '.​/​file.js'★★★★★★

TypeScript のソースコードでは .js 拡張子を指定しますが、実際には .ts ファイルを参照するという、一見矛盾した書き方が必要になります。

解決策

ESM 前提の package.json 基本テンプレート

ESM を標準とする Node.js プロジェクトでは、以下のテンプレートを基本として設計します。

最小構成の package.json

最もシンプルな ESM プロジェクトの package.json は以下のようになります。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module"
}

この "type": "module" という 1 行が、プロジェクト全体を ESM モードに切り替える鍵となります。この設定により、すべての .js ファイルが ESM として解釈されるようになるのですね。

エントリーポイントの定義

パッケージのエントリーポイントを定義する際は、main フィールドと exports フィールドを併用します。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js"
}

main フィールドは、従来の Node.js バージョンとの互換性のために残しておきます。しかし、モダンな設計では exports フィールドを使用することが推奨されますので、次のセクションで詳しく見ていきましょう。

exports フィールドの設計パターン

exports フィールドは、パッケージの公開 API を厳密に制御するための強力な機能です。適切に設計することで、意図しないファイルへのアクセスを防ぎ、パッケージの内部実装を隠蔽できます。

パターン 1: シンプルなエントリーポイント

最もシンプルな exports の設定は、メインのエントリーポイントのみを公開する形式です。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "exports": "./dist/index.js"
}

この設定により、import 'your-package-name'.​/​dist​/​index.js が読み込まれます。他のファイルには直接アクセスできなくなるため、内部実装の隠蔽が実現できますね。

パターン 2: 条件付きエクスポート

開発環境と本番環境で異なるファイルを提供したい場合は、条件付きエクスポートを使用します。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

import キーは ESM でのインポートを指定し、types キーは TypeScript の型定義ファイルを指定します。この設定により、型安全性を保ちながら ESM を使用できるのです。

パターン 3: サブパスエクスポート

パッケージ内の複数のモジュールを個別に公開したい場合は、サブパスエクスポートを使用します。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils/index.js",
    "./helpers": "./dist/helpers/index.js"
  }
}

この設定により、以下のようなインポートが可能になります。

typescript// メインモジュール
import main from 'your-package-name';

// サブモジュール
import { someUtil } from 'your-package-name/utils';
import { someHelper } from 'your-package-name/helpers';

サブパスエクスポートを使用することで、パッケージの構造を明確にし、必要な部分だけをインポートできるようになりますね。

パターン 4: ワイルドカードエクスポート

ディレクトリ配下のすべてのファイルを公開したい場合は、ワイルドカードを使用します。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./components/*": "./dist/components/*.js"
  }
}

この設定により、components ディレクトリ配下のすべての .js ファイルに対して、個別にアクセスできるようになります。

typescript// 任意のコンポーネントをインポート
import Button from 'your-package-name/components/Button';
import Input from 'your-package-name/components/Input';

ただし、ワイルドカードの使用は内部実装が露出しやすくなるため、慎重に検討する必要があります。

TypeScript との統合設定

TypeScript プロジェクトで ESM を使用する場合は、package.jsontsconfig.json の両方を適切に設定する必要があります。

package.json の TypeScript 対応設定

TypeScript でコンパイルした結果を公開する場合の package.json は以下のようになります。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

types フィールドを exports の中に含めることで、TypeScript が正しく型情報を解決できるようになります。

tsconfig.json の ESM 対応設定

TypeScript のコンパイラオプションも ESM に対応させる必要があります。

json{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "node16",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

特に重要なのは以下の設定です。

  • module: "ES2022" または "ESNext" を指定して ESM を出力
  • moduleResolution: "node16" または "nodenext" を指定して Node.js の ESM 解決ルールを使用
  • declaration: true にして型定義ファイルを生成

moduleResolution"node16" に設定することで、TypeScript は Node.js の ESM モジュール解決アルゴリズムに従うようになりますね。

インポート時の拡張子記述ルール

TypeScript で ESM を使用する場合、インポート文では .js 拡張子を明示的に指定します。

typescript// ✓ 正しい記述(.ts ファイルでも .js と書く)
import { helper } from './utils/helper.js';
import type { User } from './types/user.js';

// ✗ 誤った記述
import { helper } from './utils/helper';
import { helper } from './utils/helper.ts';

これは一見奇妙に見えますが、TypeScript は実行時のファイル構成を前提としているため、コンパイル後の .js 拡張子を指定する必要があるのです。

スクリプト設定のベストプラクティス

開発効率を高めるために、package.jsonscripts セクションも適切に設定しましょう。

json{
  "name": "your-package-name",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "clean": "rm -rf dist",
    "prebuild": "yarn clean"
  }
}

各スクリプトの役割は以下の通りです。

  • build: TypeScript をコンパイルして dist ディレクトリに出力
  • dev: ファイル変更を監視して自動的に再コンパイル
  • clean: ビルド成果物を削除
  • prebuild: build スクリプトの実行前に自動的に実行される

prebuild のように pre プレフィックスを付けることで、メインスクリプトの前処理を自動化できるのですね。

具体例

ケーススタディ 1: シンプルなユーティリティパッケージ

シンプルなユーティリティ関数を提供するパッケージを作成する例を見ていきましょう。

プロジェクト構成

typescriptyour-util-package/
├── src/
│   ├── index.ts
│   ├── string.ts
│   └── number.ts
├── dist/          # ビルド後に生成される
├── package.json
└── tsconfig.json

この構成では、src ディレクトリにソースコードを配置し、コンパイル後の成果物を dist ディレクトリに出力します。

package.json の設定

json{
  "name": "@your-scope/util-package",
  "version": "1.0.0",
  "description": "Utility functions for modern JavaScript",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

まず基本情報として、パッケージ名、バージョン、説明を定義します。type フィールドで ESM を指定し、エントリーポイントと型定義ファイルのパスを設定しているのですね。

exports フィールドの追加

次に、exports フィールドでモジュールの公開方法を定義します。

json{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./string": {
      "import": "./dist/string.js",
      "types": "./dist/string.d.ts"
    },
    "./number": {
      "import": "./dist/number.js",
      "types": "./dist/number.d.ts"
    }
  }
}

この設定により、メインモジュールだけでなく、個別のユーティリティモジュールも直接インポートできるようになります。Tree Shaking が効果的に働くため、必要な機能だけをバンドルに含めることができますね。

ソースコードの実装

src​/​string.ts にシンプルな文字列ユーティリティを実装します。

typescript/**
 * 文字列の最初の文字を大文字に変換します
 * @param str - 変換対象の文字列
 * @returns 最初の文字が大文字になった文字列
 */
export function capitalize(str: string): string {
  if (!str) return str;
  return str.charAt(0).toUpperCase() + str.slice(1);
}

各関数には JSDoc コメントを付けて、使い方を明確にします。型定義と組み合わせることで、エディタ上で優れた開発体験が得られるのです。

typescript/**
 * 文字列をケバブケースに変換します
 * @param str - 変換対象の文字列
 * @returns ケバブケースに変換された文字列
 * @example
 * toKebabCase('helloWorld') // => 'hello-world'
 */
export function toKebabCase(str: string): string {
  return str
    .replace(/([a-z])([A-Z])/g, '$1-$2')
    .toLowerCase();
}

@example タグを使用することで、具体的な使用例も提供できます。これにより、利用者が関数の挙動を素早く理解できるようになりますね。

エントリーポイントの作成

src​/​index.ts でサブモジュールを再エクスポートします。

typescript// 個別モジュールの再エクスポート
export * from './string.js';
export * from './number.js';

ESM では、インポート文で拡張子を明示する必要があるため、.​/​string.js のように記述します。実際のファイルは string.ts ですが、コンパイル後の .js ファイルを参照するのです。

利用例

このパッケージは以下のように使用できます。

typescript// すべての機能をインポート
import {
  capitalize,
  toKebabCase,
} from '@your-scope/util-package';

// または、個別にインポート(Tree Shaking に有利)
import { capitalize } from '@your-scope/util-package/string';

サブパスエクスポートにより、必要な機能だけを選択的にインポートできるため、バンドルサイズの最適化につながります。

ケーススタディ 2: 複数の実行環境をサポートするパッケージ

Node.js とブラウザの両方で動作するパッケージを作成する場合の設計を見ていきましょう。

条件付きエクスポートの活用

実行環境によって異なる実装を提供する場合は、条件付きエクスポートを使用します。

json{
  "name": "@your-scope/cross-platform-package",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "node": "./dist/node/index.js",
      "browser": "./dist/browser/index.js",
      "default": "./dist/index.js"
    }
  }
}

この設定では、実行環境に応じて適切なエントリーポイントが選択されます。

  • node: Node.js 環境で使用される
  • browser: ブラウザ環境で使用される(バンドラーが認識)
  • default: どちらにも該当しない場合のフォールバック

型定義の条件付きエクスポート

TypeScript の型定義も環境ごとに分ける場合は、以下のように設定します。

json{
  "exports": {
    ".": {
      "node": {
        "import": "./dist/node/index.js",
        "types": "./dist/node/index.d.ts"
      },
      "browser": {
        "import": "./dist/browser/index.js",
        "types": "./dist/browser/index.d.ts"
      },
      "default": {
        "import": "./dist/index.js",
        "types": "./dist/index.d.ts"
      }
    }
  }
}

条件はネストして記述でき、より細かい制御が可能になります。各環境に最適化された実装を提供できるのですね。

プロジェクト構成と実装

環境別の実装を持つプロジェクト構成は以下のようになります。

typescriptcross-platform-package/
├── src/
│   ├── node/
│   │   └── index.ts      # Node.js 専用実装
│   ├── browser/
│   │   └── index.ts      # ブラウザ専用実装
│   ├── common/
│   │   └── utils.ts      # 共通ロジック
│   └── index.ts          # 汎用エントリーポイント
├── dist/                  # ビルド後に生成
├── package.json
└── tsconfig.json

共通ロジックは common ディレクトリに配置し、環境固有の実装はそれぞれのディレクトリに分けます。

Node.js 専用実装の例

src​/​node​/​index.ts では、Node.js の API を使用した実装を提供します。

typescriptimport { readFile } from 'node:fs/promises';

/**
 * ファイルを読み込んで JSON としてパースします(Node.js 専用)
 * @param filePath - 読み込むファイルのパス
 * @returns パースされた JSON オブジェクト
 */
export async function loadJSON<T>(
  filePath: string
): Promise<T> {
  const content = await readFile(filePath, 'utf-8');
  return JSON.parse(content);
}

node:fs​/​promises のような Node.js 組み込みモジュールを使用しています。node: プレフィックスを付けることで、明示的に Node.js の組み込みモジュールであることを示せるのですね。

ブラウザ専用実装の例

src​/​browser​/​index.ts では、Fetch API を使用した実装を提供します。

typescript/**
 * URL から JSON を取得します(ブラウザ専用)
 * @param url - 取得する JSON の URL
 * @returns パースされた JSON オブジェクト
 */
export async function loadJSON<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(
      `HTTP error! status: ${response.status}`
    );
  }
  return response.json();
}

同じ関数名でも、実装は実行環境に応じて異なります。パッケージ利用者は、環境を意識せずに同じインターフェースで使用できるのです。

以下の図は、条件付きエクスポートによる環境別のモジュール解決を示しています。

mermaidflowchart TD
  import_stmt["import { loadJSON }<br/>from 'pkg'"] --> resolver["モジュール<br/>解決"]
  resolver --> check_env{"実行環境<br/>判定"}
  check_env -->|Node.js| node_impl["dist/node/<br/>index.js"]
  check_env -->|Browser| browser_impl["dist/browser/<br/>index.js"]
  check_env -->|その他| default_impl["dist/<br/>index.js"]
  node_impl --> result1["Node.js API<br/>を使用"]
  browser_impl --> result2["Fetch API<br/>を使用"]
  default_impl --> result3["汎用実装<br/>を使用"]

この仕組みにより、1 つのパッケージで複数の実行環境をサポートできるようになります。

ケーススタディ 3: モノレポでの exports 活用

複数のパッケージを含むモノレポ構成で、exports を効果的に活用する例を見ていきましょう。

モノレポ構成

typescriptmonorepo/
├── packages/
│   ├── core/
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── ui/
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── package.json
└── yarn.lock

各パッケージは独立した package.json を持ち、相互に依存関係を持つことができます。

ワークスペースの設定

ルートの package.json でワークスペースを定義します。

json{
  "name": "monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "type": "module"
}

private: true により、ルートパッケージ自体は npm に公開されないようにします。ワークスペース配下の各パッケージは個別に公開できるのですね。

内部パッケージの exports 設定

packages​/​core​/​package.json での設定例です。

json{
  "name": "@monorepo/core",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./internal": {
      "import": "./dist/internal/index.js",
      "types": "./dist/internal/index.d.ts"
    }
  }
}

.​/​internal サブパスは、モノレポ内の他のパッケージからのみ使用することを想定した内部 API を公開します。

パッケージ間の依存関係

packages​/​ui​/​package.json で core パッケージに依存する設定です。

json{
  "name": "@monorepo/ui",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@monorepo/core": "workspace:*"
  },
  "exports": {
    ".": "./dist/index.js",
    "./components/*": "./dist/components/*.js"
  }
}

workspace:* を使用することで、ワークスペース内のパッケージを参照できます。Yarn Workspaces では、この記法によりシンボリックリンクが作成され、ローカル開発がスムーズになるのですね。

内部パッケージの利用

packages​/​ui​/​src​/​index.ts で core パッケージを使用する例です。

typescript// 公開 API のインポート
import { createApp } from '@monorepo/core';

// 内部 API のインポート(モノレポ内でのみ使用)
import { internalHelper } from '@monorepo/core/internal';

/**
 * UI コンポーネントを初期化します
 */
export function initUI() {
  const app = createApp();
  // internalHelper は外部には公開されない
  const config = internalHelper();
  return app;
}

このように、exports フィールドを使用することで、パッケージの公開 API と内部 API を明確に分離できます。

まとめ

ESM を前提とした Node.js プロジェクトの初期化には、package.json の適切な設計が不可欠です。特に以下のポイントを押さえることで、モダンで保守性の高いプロジェクトを構築できます。

重要な設計ポイント:

#項目設定内容効果
1type フィールド"type": "module"プロジェクト全体を ESM モードに
2exports フィールドサブパスエクスポートモジュールの公開範囲を制御
3TypeScript 統合types の明示型安全性の確保
4条件付きエクスポート環境別の実装クロスプラットフォーム対応
5拡張子の明示.js を記述ESM の仕様に準拠

exports フィールドは、単なるエントリーポイントの指定以上の価値があります。パッケージの内部実装を隠蔽し、公開 API を明確にすることで、長期的なメンテナンス性が大幅に向上するのです。

また、TypeScript との統合では、コンパイル後のファイル構成を意識した設定が重要になります。moduleResolution: "node16" と拡張子の明示的な記述により、型チェックと実行時の整合性を保つことができますね。

モノレポ構成では、ワークスペース機能と exports を組み合わせることで、複数のパッケージを効率的に管理できます。内部 API と公開 API を分離することで、パッケージ間の依存関係も明確になります。

これらのベストプラクティスを活用して、将来性のある Node.js プロジェクトを構築していきましょう。ESM への移行は一見複雑に見えますが、適切な初期設定により、その後の開発がスムーズに進められるようになります。

関連リンク