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/export | require |
|---|---|---|---|---|
| 1 | CommonJS | .js, .cjs | ✗ | ★★★ |
| 2 | ESM | .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.json の type フィールドによって決まります。ESM を標準にするには、この設定が重要になってくるのですね。
なぜ ESM を標準にすべきなのか
モダンな開発環境では、以下の理由から ESM を標準とすることが推奨されます。
- 標準仕様への準拠: ECMAScript の公式仕様として策定されている
- ブラウザとの互換性: 同じモジュール構文をブラウザでも使える
- 静的解析の容易さ: ビルドツールによる最適化がしやすい
- 非同期読み込み: Top-level await が使える
- 将来性: Node.js の開発方針も ESM を中心に進んでいる
このような背景から、新規プロジェクトでは ESM を前提とした設計を採用することが、長期的なメンテナンス性の向上につながります。
課題
ESM 移行における混乱ポイント
ESM を前提としたプロジェクトを始める際、多くの開発者が以下のような課題に直面します。
課題 1: package.json の type フィールド設定ミス
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 モードで拡張子を省略してインポートした場合
解決方法:
- インポート文に
.js拡張子を明示的に追加する - TypeScript を使用している場合は、
tsconfig.jsonでmoduleResolutionを適切に設定する - エディタの自動インポート機能が拡張子を含めるように設定する
課題 4: TypeScript との統合
TypeScript で開発する場合、.ts ファイルをコンパイルした結果が .js になるため、インポート文での拡張子の扱いに注意が必要です。
| # | 記述方法 | TypeScript | 実行時 |
|---|---|---|---|
| 1 | import './file' | ★★★ | ✗ |
| 2 | import './file.ts' | ✗ | ✗ |
| 3 | import './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.json と tsconfig.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.json の scripts セクションも適切に設定しましょう。
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 の適切な設計が不可欠です。特に以下のポイントを押さえることで、モダンで保守性の高いプロジェクトを構築できます。
重要な設計ポイント:
| # | 項目 | 設定内容 | 効果 |
|---|---|---|---|
| 1 | type フィールド | "type": "module" | プロジェクト全体を ESM モードに |
| 2 | exports フィールド | サブパスエクスポート | モジュールの公開範囲を制御 |
| 3 | TypeScript 統合 | types の明示 | 型安全性の確保 |
| 4 | 条件付きエクスポート | 環境別の実装 | クロスプラットフォーム対応 |
| 5 | 拡張子の明示 | .js を記述 | ESM の仕様に準拠 |
exports フィールドは、単なるエントリーポイントの指定以上の価値があります。パッケージの内部実装を隠蔽し、公開 API を明確にすることで、長期的なメンテナンス性が大幅に向上するのです。
また、TypeScript との統合では、コンパイル後のファイル構成を意識した設定が重要になります。moduleResolution: "node16" と拡張子の明示的な記述により、型チェックと実行時の整合性を保つことができますね。
モノレポ構成では、ワークスペース機能と exports を組み合わせることで、複数のパッケージを効率的に管理できます。内部 API と公開 API を分離することで、パッケージ間の依存関係も明確になります。
これらのベストプラクティスを活用して、将来性のある Node.js プロジェクトを構築していきましょう。ESM への移行は一見複雑に見えますが、適切な初期設定により、その後の開発がスムーズに進められるようになります。
関連リンク
articleNode.js プロジェクト初期化テンプレ:ESM 前提の `package.json` 設計と `exports` ルール
articleNode.js 標準テストランナー完全理解:`node:test` がもたらす新しい DX
articleNode.js で ESM の `ERR_MODULE_NOT_FOUND` を解く:解決策総当たりチェックリスト
articleNode.js 本番メモリ運用:ヒープ/外部メモリ/リーク検知の継続監視
articleNode.js で社内 RPA:Playwright でブラウザ自動化&失敗回復の流儀
articleNode.js × Fastify で爆速 REST API:スキーマ駆動とプラグイン設計を学ぶ
articlegpt-oss アーキテクチャを分解図で理解する:推論ランタイム・トークナイザ・サービング層の役割
articlePHP で社内業務自動化:CSV→DB 取込・定期バッチ・Slack 通知の実例
articleGPT-5 × Cloudflare Workers/Edge:低遅延サーバーレスのスターターガイド
articleNotebookLM と Notion AI/ChatGPT の比較:根拠提示とソース管理の違い
articleFlutter で ToDo アプリを 90 分で作る:状態管理・永続化・ダークモード対応
articleEmotion のパフォーマンス監視運用:Web Vitals× トレースでボトルネック特定
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来