はじめに

Node.jsでモダンなJavaScriptプロジェクトを構築する際、**ES Modules(ESM)**の理解は欠かせません。ES Modulesは、ECMAScript標準として策定されたモジュールシステムであり、ブラウザとサーバーサイドの両方でネイティブにサポートされています。

本記事では、ES Modulesの各種機能を深掘りし、実践的なNode.jsプロジェクトを構築するための知識を提供します。

  • 名前付きエクスポートとデフォルトエクスポートの使い分け
  • import.metaオブジェクトの全プロパティ(urldirnamefilenameresolve
  • 動的import()の活用パターン
  • Top-level awaitによるモジュール初期化
  • ESMプロジェクトのベストプラクティス

この記事を読み終えると、ES Modulesを使用したモダンなNode.jsプロジェクトを自信を持って構築できるようになります。

実行環境

本記事のコードは以下の環境で動作確認しています。

項目 バージョン
Node.js 20.x LTS以上
npm 10.x以上
OS Windows/macOS/Linux

前提条件

  • JavaScriptの基礎知識(変数、関数、クラス、async/await)
  • Node.jsプロジェクトの初期化経験(npm init
  • CommonJSとES Modulesの違いの基本的な理解

ES ModulesとCommonJSの違いについては、Node.jsモジュール入門 - CommonJSとES Modulesの違いを理解するを参照してください。

export(エクスポート)の詳細

ES Modulesでは、exportキーワードを使ってモジュールから値を公開します。エクスポートには「名前付きエクスポート」と「デフォルトエクスポート」の2種類があり、それぞれ適切な場面で使い分けることが重要です。

名前付きエクスポート(Named Export)

名前付きエクスポートは、複数の値を個別の名前で公開する方法です。ユーティリティ関数や定数を複数公開する場合に適しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// utils/math.js

// 方法1: 宣言時にexportを付ける
export const PI = 3.14159265359;
export const E = 2.71828182845;

export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export function multiply(a, b) {
  return a * b;
}

export function divide(a, b) {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}

ファイル末尾でまとめてエクスポートすることも可能です。

1
2
3
4
5
6
7
8
9
// utils/string.js

const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
const lowercase = (str) => str.toLowerCase();
const uppercase = (str) => str.toUpperCase();
const trim = (str) => str.trim();

// 方法2: まとめてエクスポート
export { capitalize, lowercase, uppercase, trim };

エクスポート時のリネーム

asキーワードを使用して、エクスポート時に別名を付けることができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// utils/validators.js

function validateEmail(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

function validatePhone(phone) {
  const regex = /^\d{10,11}$/;
  return regex.test(phone);
}

// 別名でエクスポート
export {
  validateEmail as isValidEmail,
  validatePhone as isValidPhone,
};

デフォルトエクスポート(Default Export)

デフォルトエクスポートは、モジュールのメイン機能を1つだけ公開する方法です。1つのモジュールにつき、デフォルトエクスポートは1つだけ定義できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// services/UserService.js

class UserService {
  constructor(apiClient) {
    this.apiClient = apiClient;
  }

  async findById(id) {
    return await this.apiClient.get(`/users/${id}`);
  }

  async findAll() {
    return await this.apiClient.get('/users');
  }

  async create(userData) {
    return await this.apiClient.post('/users', userData);
  }

  async update(id, userData) {
    return await this.apiClient.put(`/users/${id}`, userData);
  }

  async delete(id) {
    return await this.apiClient.delete(`/users/${id}`);
  }
}

// デフォルトエクスポート
export default UserService;

関数やオブジェクトも直接デフォルトエクスポートできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// config/database.js

export default {
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT) || 5432,
  database: process.env.DB_NAME || 'myapp',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || '',
  ssl: process.env.NODE_ENV === 'production',
};

名前付きエクスポートとデフォルトエクスポートの併用

1つのモジュールで両方のエクスポート方式を併用することも可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// http/client.js

// デフォルトエクスポート: メインのクライアントクラス
export default class HttpClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async get(path) {
    const response = await fetch(`${this.baseURL}${path}`);
    return response.json();
  }

  async post(path, data) {
    const response = await fetch(`${this.baseURL}${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return response.json();
  }
}

// 名前付きエクスポート: ヘルパー関数
export function createHeaders(token) {
  return {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  };
}

export function handleError(error) {
  console.error('HTTP Error:', error.message);
  throw error;
}

// 名前付きエクスポート: 定数
export const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  NOT_FOUND: 404,
  INTERNAL_SERVER_ERROR: 500,
};

再エクスポート(Re-export)

複数のモジュールから値を集約して、単一のエントリーポイントから公開することができます。

1
2
3
4
5
6
7
8
9
// utils/index.js

// 他のモジュールから再エクスポート
export { add, subtract, multiply, divide, PI, E } from './math.js';
export { capitalize, lowercase, uppercase, trim } from './string.js';
export { isValidEmail, isValidPhone } from './validators.js';

// デフォルトエクスポートを名前付きで再エクスポート
export { default as DateFormatter } from './DateFormatter.js';

import(インポート)の詳細

import文を使用して、他のモジュールからエクスポートされた値を読み込みます。

名前付きインポート

名前付きエクスポートされた値をインポートする場合は、波括弧{}を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// main.js

// 個別にインポート
import { add, subtract, PI } from './utils/math.js';

console.log(add(10, 5));      // 15
console.log(subtract(10, 5)); // 5
console.log(PI);              // 3.14159265359

// インポート時にリネーム
import { isValidEmail as checkEmail } from './utils/validators.js';

console.log(checkEmail('test@example.com')); // true

デフォルトインポート

デフォルトエクスポートされた値は、任意の名前でインポートできます。

1
2
3
4
5
6
7
// app.js

import UserService from './services/UserService.js';
import dbConfig from './config/database.js';

const userService = new UserService(apiClient);
console.log(dbConfig.host); // localhost

名前空間インポート

モジュール全体を1つのオブジェクトとしてインポートすることができます。

1
2
3
4
5
6
7
// analytics.js

import * as MathUtils from './utils/math.js';

console.log(MathUtils.add(1, 2));    // 3
console.log(MathUtils.PI);           // 3.14159265359
console.log(MathUtils.multiply(3, 4)); // 12

混合インポート

デフォルトインポートと名前付きインポートを同時に行うことができます。

1
2
3
4
5
6
7
8
// api-client.js

import HttpClient, { createHeaders, HTTP_STATUS } from './http/client.js';

const client = new HttpClient('https://api.example.com');
const headers = createHeaders('my-token');

console.log(HTTP_STATUS.OK); // 200

Node.js組み込みモジュールのインポート

Node.jsの組み込みモジュールは、node:プレフィックスを付けてインポートすることが推奨されています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// file-utils.js

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { EventEmitter } from 'node:events';

// fsモジュールの使用例
const content = await fs.readFile('data.txt', 'utf-8');
console.log(content);

node:プレフィックスを使用することで、組み込みモジュールとサードパーティパッケージを明確に区別できます。

import.metaオブジェクト

import.metaは、ES Modulesでのみ使用可能なメタプロパティオブジェクトです。現在のモジュールに関する情報を提供します。

import.meta.url

import.meta.urlは、現在のモジュールファイルの絶対URLを文字列で返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// src/config/loader.js

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

console.log(import.meta.url);
// 出力例: file:///C:/projects/myapp/src/config/loader.js

// URLオブジェクトを使用した相対パス解決
const configPath = new URL('./settings.json', import.meta.url);
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
console.log(config);

import.meta.dirname(Node.js 20.11.0以降)

import.meta.dirnameは、現在のモジュールが存在するディレクトリの絶対パスを返します。CommonJSの__dirnameに相当します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// src/utils/paths.js

console.log(import.meta.dirname);
// 出力例: C:\projects\myapp\src\utils
// または: /home/user/projects/myapp/src/utils

// ディレクトリパスを使用した操作
import path from 'node:path';

const dataDir = path.join(import.meta.dirname, '..', 'data');
console.log(dataDir);
// 出力例: C:\projects\myapp\src\data

import.meta.filename(Node.js 20.11.0以降)

import.meta.filenameは、現在のモジュールファイルの絶対パスを返します。CommonJSの__filenameに相当します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// src/logger/index.js

console.log(import.meta.filename);
// 出力例: C:\projects\myapp\src\logger\index.js
// または: /home/user/projects/myapp/src/logger/index.js

import path from 'node:path';

// ファイル名のみを取得
const fileName = path.basename(import.meta.filename);
console.log(fileName); // index.js

// 拡張子を除いたファイル名
const baseName = path.basename(import.meta.filename, '.js');
console.log(baseName); // index

Node.js 20.11.0より前のバージョンでの代替手法

import.meta.dirnameimport.meta.filenameがサポートされていない古いバージョンでは、import.meta.urlから導出します。

1
2
3
4
5
6
7
8
9
// 互換性のある実装
import { fileURLToPath } from 'node:url';
import path from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

console.log(__filename); // /path/to/current/file.js
console.log(__dirname);  // /path/to/current

import.meta.resolve()

import.meta.resolve()は、モジュール指定子を絶対URLに解決します。require.resolve()のES Module版です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/plugins/loader.js

// 相対パスの解決
const utilsPath = import.meta.resolve('./utils.js');
console.log(utilsPath);
// 出力例: file:///C:/projects/myapp/src/plugins/utils.js

// パッケージの解決
const lodashPath = import.meta.resolve('lodash');
console.log(lodashPath);
// 出力例: file:///C:/projects/myapp/node_modules/lodash/lodash.js

// サブパスの解決
const lodashGetPath = import.meta.resolve('lodash/get.js');
console.log(lodashGetPath);
// 出力例: file:///C:/projects/myapp/node_modules/lodash/get.js

import.meta.resolve()は同期的に動作し、実際のファイル読み込みは行いません。モジュールパスの事前検証やアセットパスの解決に便利です。

1
2
3
4
5
6
7
8
9
// アセットパスの解決例
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

// パッケージ内のアセットファイルのパスを取得
const cssPath = import.meta.resolve('bootstrap/dist/css/bootstrap.min.css');
const cssFilePath = fileURLToPath(cssPath);

console.log('Bootstrap CSS path:', cssFilePath);

動的import()

動的import()は、実行時にモジュールを非同期で読み込む機能です。静的なimport文とは異なり、条件分岐やループ内でも使用できます。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// main.js

async function loadModule() {
  // 動的にモジュールをインポート
  const { add, multiply } = await import('./utils/math.js');
  
  console.log(add(2, 3));      // 5
  console.log(multiply(4, 5)); // 20
}

loadModule();

条件付きインポート

実行環境や条件に応じて、異なるモジュールを読み込むことができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// logger.js

async function createLogger() {
  const env = process.env.NODE_ENV;
  
  if (env === 'production') {
    // 本番環境用の高機能ロガー
    const { default: ProductionLogger } = await import('./loggers/production.js');
    return new ProductionLogger();
  } else {
    // 開発環境用のシンプルなロガー
    const { default: DevelopmentLogger } = await import('./loggers/development.js');
    return new DevelopmentLogger();
  }
}

const logger = await createLogger();
logger.info('Application started');

プラグインシステムの実装

動的import()を活用して、プラグインシステムを実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// plugins/loader.js

import fs from 'node:fs/promises';
import path from 'node:path';

class PluginLoader {
  constructor(pluginDir) {
    this.pluginDir = pluginDir;
    this.plugins = new Map();
  }

  async loadAll() {
    const files = await fs.readdir(this.pluginDir);
    const jsFiles = files.filter(f => f.endsWith('.js'));
    
    for (const file of jsFiles) {
      const pluginPath = path.join(this.pluginDir, file);
      await this.load(pluginPath);
    }
    
    return this.plugins;
  }

  async load(pluginPath) {
    try {
      // 動的インポートでプラグインを読み込み
      const module = await import(`file://${pluginPath}`);
      const plugin = module.default || module;
      
      if (plugin.name && typeof plugin.init === 'function') {
        this.plugins.set(plugin.name, plugin);
        await plugin.init();
        console.log(`Plugin loaded: ${plugin.name}`);
      }
    } catch (error) {
      console.error(`Failed to load plugin: ${pluginPath}`, error);
    }
  }

  get(name) {
    return this.plugins.get(name);
  }
}

export default PluginLoader;

遅延読み込み(Lazy Loading)

必要になるまでモジュールの読み込みを遅延させることで、初期起動時間を短縮できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// services/analytics.js

let analyticsModule = null;

export async function trackEvent(eventName, eventData) {
  // 初回呼び出し時のみモジュールを読み込む
  if (!analyticsModule) {
    analyticsModule = await import('./heavy-analytics-sdk.js');
    await analyticsModule.initialize();
  }
  
  analyticsModule.track(eventName, eventData);
}

export async function trackPageView(pagePath) {
  if (!analyticsModule) {
    analyticsModule = await import('./heavy-analytics-sdk.js');
    await analyticsModule.initialize();
  }
  
  analyticsModule.pageView(pagePath);
}

JSONのインポート

ES Modulesでは、JSONファイルをインポートする際にimport attributeswith構文)が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// config/loader.js

// 静的インポート(import attributes使用)
import packageJson from '../package.json' with { type: 'json' };

console.log(packageJson.name);
console.log(packageJson.version);

// 動的インポートでのJSON読み込み
async function loadConfig(configName) {
  const config = await import(`./configs/${configName}.json`, {
    with: { type: 'json' }
  });
  return config.default;
}

const dbConfig = await loadConfig('database');
console.log(dbConfig);

Top-level await

Top-level awaitは、ES Modulesのトップレベル(関数の外)でawaitを使用できる機能です。モジュールの初期化時に非同期処理を実行する場合に便利です。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// config/database.js

import { createConnection } from './db-client.js';

// Top-level awaitでデータベース接続を初期化
const connection = await createConnection({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
});

console.log('Database connected');

export default connection;
1
2
3
4
5
6
7
8
// main.js

// database.jsがインポートされた時点で、接続が確立されている
import db from './config/database.js';

// すぐにデータベース操作が可能
const users = await db.query('SELECT * FROM users');
console.log(users);

設定ファイルの非同期読み込み

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// config/index.js

import fs from 'node:fs/promises';
import path from 'node:path';

const configPath = path.join(import.meta.dirname, 'app-config.json');
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent);

// 環境変数でオーバーライド
config.port = parseInt(process.env.PORT) || config.port;
config.logLevel = process.env.LOG_LEVEL || config.logLevel;

export default Object.freeze(config);

APIからの初期設定取得

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// config/remote.js

async function fetchRemoteConfig(endpoint) {
  const response = await fetch(endpoint);
  if (!response.ok) {
    throw new Error(`Failed to fetch config: ${response.status}`);
  }
  return response.json();
}

// サーバー起動時にリモート設定を取得
const remoteConfig = await fetchRemoteConfig(
  process.env.CONFIG_ENDPOINT || 'https://config.example.com/api/settings'
);

export const featureFlags = remoteConfig.features;
export const apiEndpoints = remoteConfig.endpoints;
export default remoteConfig;

Top-level awaitの注意点

Top-level awaitを使用する際は、以下の点に注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 注意点1: 解決されないPromiseはプロセスを停止させる

// 悪い例: 永遠に解決されないPromise
// await new Promise(() => {}); // プロセスがexit code 13で終了

// 良い例: タイムアウトを設定する
const timeout = (ms) => new Promise((_, reject) => 
  setTimeout(() => reject(new Error('Timeout')), ms)
);

const config = await Promise.race([
  fetchConfig(),
  timeout(5000)
]).catch(error => {
  console.error('Config fetch failed, using defaults:', error.message);
  return defaultConfig;
});

export default config;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 注意点2: エラーハンドリング

// 悪い例: エラーが握り潰される可能性
const data = await riskyOperation();

// 良い例: 明示的なエラーハンドリング
let data;
try {
  data = await riskyOperation();
} catch (error) {
  console.error('Failed to initialize:', error);
  process.exit(1);
}

export default data;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 注意点3: インポートチェーンへの影響
// Top-level awaitを持つモジュールをインポートすると、
// そのawaitが完了するまでインポート元も待機する

// database.js - 接続に3秒かかる場合
export const db = await connect(); // 3秒待機

// app.js - database.jsのインポートで3秒待機
import { db } from './database.js'; // この時点で3秒待機

console.log('App started'); // database.jsの初期化完了後に実行

ESMプロジェクトのベストプラクティス

package.jsonの設定

ESMプロジェクトでは、package.json"type": "module"を設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "name": "my-esm-project",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=20.0.0"
  },
  "exports": {
    ".": {
      "import": "./src/index.js",
      "types": "./src/index.d.ts"
    },
    "./utils": {
      "import": "./src/utils/index.js",
      "types": "./src/utils/index.d.ts"
    }
  },
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js",
    "test": "node --test"
  }
}

ディレクトリ構造

推奨されるESMプロジェクトのディレクトリ構造を示します。

graph TD
    A[my-esm-project/] --> B[src/]
    A --> C[tests/]
    A --> D[package.json]
    
    B --> E[index.js]
    B --> F[config/]
    B --> G[services/]
    B --> H[utils/]
    B --> I[models/]
    
    F --> J[index.js]
    F --> K[database.js]
    
    G --> L[index.js]
    G --> M[UserService.js]
    
    H --> N[index.js]
    H --> O[math.js]
    H --> P[string.js]
    
    C --> Q[unit/]
    C --> R[integration/]

インデックスファイルによるバレルパターン

関連するモジュールをindex.jsで集約し、インポートを簡潔にします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// src/utils/index.js

// 各ユーティリティから再エクスポート
export * from './math.js';
export * from './string.js';
export * from './date.js';
export * from './validators.js';

// デフォルトエクスポートは名前付きで再エクスポート
export { default as Logger } from './Logger.js';
export { default as Cache } from './Cache.js';
1
2
3
4
5
6
7
// src/app.js

// 集約されたインポート
import { add, capitalize, formatDate, Logger } from './utils/index.js';

const logger = new Logger();
logger.info(`Result: ${add(1, 2)}`);

ファイル拡張子の明示

ES Modulesでは、相対パスでインポートする際にファイル拡張子を必ず指定します。

1
2
3
4
5
6
7
// 良い例: 拡張子を明示
import { add } from './utils/math.js';
import config from './config/index.js';

// 悪い例: 拡張子を省略(エラーになる)
// import { add } from './utils/math';
// import config from './config';

環境変数の管理

ESMプロジェクトでの環境変数管理パターンを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/config/env.js

import { config } from 'dotenv';

// .envファイルを読み込み
config();

// 必須環境変数のバリデーション
const requiredEnvVars = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}

// 型安全な環境変数オブジェクト
export const env = Object.freeze({
  nodeEnv: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT) || 3000,
  databaseUrl: process.env.DATABASE_URL,
  apiKey: process.env.API_KEY,
  jwtSecret: process.env.JWT_SECRET,
  logLevel: process.env.LOG_LEVEL || 'info',
  isDevelopment: process.env.NODE_ENV === 'development',
  isProduction: process.env.NODE_ENV === 'production',
});

エラーハンドリングのパターン

ESMプロジェクトでの統一的なエラーハンドリングパターンを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/errors/AppError.js

export class AppError extends Error {
  constructor(message, statusCode = 500, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404);
  }
}

export class ValidationError extends AppError {
  constructor(message) {
    super(message, 400);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/middleware/errorHandler.js

import { AppError } from '../errors/AppError.js';

export function errorHandler(error, req, res, next) {
  if (error instanceof AppError) {
    return res.status(error.statusCode).json({
      status: 'error',
      message: error.message,
    });
  }

  // 予期しないエラー
  console.error('Unexpected error:', error);
  return res.status(500).json({
    status: 'error',
    message: 'Internal server error',
  });
}

モジュールの依存関係図

大規模なESMプロジェクトでは、モジュール間の依存関係を明確に設計することが重要です。

graph TB
    subgraph "Entry Points"
        A[src/index.js]
    end
    
    subgraph "Application Layer"
        B[src/app.js]
        C[src/routes/]
    end
    
    subgraph "Service Layer"
        D[src/services/UserService.js]
        E[src/services/OrderService.js]
    end
    
    subgraph "Data Access Layer"
        F[src/repositories/UserRepository.js]
        G[src/repositories/OrderRepository.js]
    end
    
    subgraph "Infrastructure"
        H[src/config/database.js]
        I[src/utils/]
    end
    
    A --> B
    B --> C
    C --> D
    C --> E
    D --> F
    E --> G
    F --> H
    G --> H
    D --> I
    E --> I

ESMとCommonJSの相互運用

ESMプロジェクトでCommonJSモジュールを使用する場合の注意点を説明します。

CommonJSモジュールのインポート

1
2
3
4
5
6
7
8
9
// ESMからCommonJSモジュールをインポート

// デフォルトインポート(推奨)
import lodash from 'lodash';
console.log(lodash.chunk([1, 2, 3, 4], 2));

// 名前付きインポート(一部のパッケージでサポート)
import { chunk, groupBy } from 'lodash';
console.log(chunk([1, 2, 3, 4], 2));

createRequireを使用したrequireの再現

どうしてもrequireが必要な場合は、module.createRequire()を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// src/legacy-support.js

import { createRequire } from 'node:module';

// ESM内でrequire関数を作成
const require = createRequire(import.meta.url);

// CommonJSモジュールをrequireでインポート
const legacyModule = require('./legacy-cjs-module.cjs');

// JSON読み込み(import attributesの代替)
const packageJson = require('../package.json');

console.log(packageJson.version);

まとめ

本記事では、Node.jsのES Modulesについて詳しく解説しました。

ES Modulesの主要な機能を振り返ります。

機能 説明 ユースケース
名前付きエクスポート 複数の値を個別の名前で公開 ユーティリティ関数、定数
デフォルトエクスポート モジュールのメイン機能を1つ公開 クラス、メイン関数
import.meta.url モジュールの絶対URL 相対パス解決
import.meta.dirname ディレクトリパス ファイル操作
import.meta.filename ファイルパス ログ、デバッグ
import.meta.resolve() モジュールパス解決 パッケージ内アセット取得
動的import() 実行時のモジュール読み込み 条件付きインポート、プラグイン
Top-level await トップレベルでの非同期処理 設定の初期化、DB接続

ES Modulesを使用することで、よりモダンで保守性の高いNode.jsプロジェクトを構築できます。静的解析ツールやバンドラーとの相性も良く、長期的なプロジェクトの品質向上に貢献します。

参考リンク