ファイルパスの操作はサーバーサイド開発において頻繁に発生する処理ですが、OSごとにパス区切り文字が異なるため、直接文字列を連結するとクロスプラットフォーム対応に問題が生じます。Node.jsのpathモジュールは、これらのOS間の違いを吸収し、一貫したパス操作を実現するための標準ライブラリです。

本記事では、pathモジュールの主要なAPIを網羅的に解説し、実践的なユースケースとともにクロスプラットフォーム開発のベストプラクティスを紹介します。

実行環境

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

pathモジュールの基本

pathモジュールはnode:pathという形式でインポートします。

1
2
3
4
5
// CommonJS
const path = require('node:path');

// ES Modules
import path from 'node:path';

このモジュールは実行中のOSに応じて、適切なパス区切り文字や処理ロジックを自動的に選択します。

WindowsとPOSIXの違い

pathモジュールのデフォルト動作は、Node.jsが動作しているOSによって異なります。

パス区切り文字の違い

OS パス区切り文字
Windows \(バックスラッシュ) C:\Users\name\file.txt
POSIX(macOS/Linux) /(スラッシュ) /home/name/file.txt

path.sep

path.sepはプラットフォーム固有のパス区切り文字を提供します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import path from 'node:path';

console.log(path.sep);
// Windows: '\\'
// POSIX: '/'

// パスをセグメントに分割
const filePath = 'foo/bar/baz';
console.log(filePath.split(path.sep));
// POSIX: ['foo', 'bar', 'baz']

path.delimiter

path.delimiterは環境変数PATHなどで使用される区切り文字を提供します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import path from 'node:path';

console.log(path.delimiter);
// Windows: ';'
// POSIX: ':'

// 環境変数PATHを配列に分割
const paths = process.env.PATH.split(path.delimiter);
console.log(paths);
// Windows: ['C:\\Windows\\system32', 'C:\\Windows', ...]
// POSIX: ['/usr/bin', '/bin', '/usr/sbin', ...]

パス結合API

path.join()

path.join()は複数のパスセグメントをプラットフォーム固有の区切り文字で結合し、正規化されたパスを返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import path from 'node:path';

// 基本的な結合
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux'));
// '/foo/bar/baz/asdf/quux'

// 相対パス(..)を含む結合
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '..'));
// '/foo/bar/baz/asdf'

// 空文字列はカレントディレクトリ(.)として扱われる
console.log(path.join('foo', '', 'bar'));
// 'foo/bar'

path.join()の特徴は以下の通りです。

  • 相対パス(...)を解決する
  • 連続するスラッシュを単一の区切り文字に正規化する
  • 結果が空文字列の場合は.を返す

path.resolve()

path.resolve()は右から左へパスセグメントを処理し、絶対パスを構築します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import path from 'node:path';

// 相対パスから絶対パスを構築
console.log(path.resolve('/foo/bar', './baz'));
// '/foo/bar/baz'

// 途中で絶対パスが現れると、それ以前のセグメントは無視される
console.log(path.resolve('/foo/bar', '/tmp/file/'));
// '/tmp/file'

// 相対パスのみの場合、カレントワーキングディレクトリを起点とする
console.log(path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif'));
// 例: '/home/user/project/wwwroot/static_files/gif/image.gif'

path.join()とpath.resolve()の違い

両者の違いを理解することは重要です。

1
2
3
4
5
6
7
8
9
import path from 'node:path';

// path.join() は単純に連結
console.log(path.join('/foo', '/bar'));
// '/foo/bar'

// path.resolve() は右から絶対パスを探す
console.log(path.resolve('/foo', '/bar'));
// '/bar' (/barが絶対パスなので、/fooは無視される)
flowchart LR
    subgraph "path.join()"
        A1["/foo"] --> B1["結合"] 
        A2["bar"] --> B1
        B1 --> C1["/foo/bar"]
    end
    
    subgraph "path.resolve()"
        D1["/foo"] --> E1["右から処理"]
        D2["/bar"] --> E1
        E1 --> F1["/bar<br>(絶対パス発見で終了)"]
    end

パス分解API

path.dirname()

path.dirname()はパスからディレクトリ部分を抽出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import path from 'node:path';

console.log(path.dirname('/foo/bar/baz/asdf/quux'));
// '/foo/bar/baz/asdf'

console.log(path.dirname('/foo/bar/baz/'));
// '/foo/bar'

console.log(path.dirname('file.txt'));
// '.'

path.basename()

path.basename()はパスから最後の部分(ファイル名)を抽出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import path from 'node:path';

// ファイル名を取得
console.log(path.basename('/foo/bar/baz/asdf/quux.html'));
// 'quux.html'

// 拡張子を除外してファイル名を取得
console.log(path.basename('/foo/bar/baz/asdf/quux.html', '.html'));
// 'quux'

// 末尾の区切り文字は無視される
console.log(path.basename('/foo/bar/baz/'));
// 'baz'

path.extname()

path.extname()はパスから拡張子を抽出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import path from 'node:path';

console.log(path.extname('index.html'));
// '.html'

console.log(path.extname('index.coffee.md'));
// '.md' (最後のドット以降)

console.log(path.extname('index.'));
// '.'

console.log(path.extname('index'));
// '' (拡張子なし)

console.log(path.extname('.index'));
// '' (隠しファイル扱い)

console.log(path.extname('.index.md'));
// '.md'

パス解析と構築

path.parse()

path.parse()はパス文字列を構成要素に分解したオブジェクトを返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import path from 'node:path';

// POSIXの例
const parsed = path.parse('/home/user/dir/file.txt');
console.log(parsed);
// {
//   root: '/',
//   dir: '/home/user/dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

パス構造を図で表すと以下のようになります。

graph TB
    subgraph "path.parse() の結果"
        A["root: '/'"] 
        B["dir: '/home/user/dir'"]
        C["base: 'file.txt'"]
        D["name: 'file'"]
        E["ext: '.txt'"]
    end
    
    F["'/home/user/dir/file.txt'"] --> A
    F --> B
    F --> C
    C --> D
    C --> E

Windowsパスの場合は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import path from 'node:path';

// Windowsの例(path.win32を使用)
const parsed = path.win32.parse('C:\\path\\dir\\file.txt');
console.log(parsed);
// {
//   root: 'C:\\',
//   dir: 'C:\\path\\dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

path.format()

path.format()path.parse()の逆操作で、オブジェクトからパス文字列を構築します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import path from 'node:path';

// 基本的な使用例
const filePath = path.format({
  root: '/',
  dir: '/home/user/dir',
  base: 'file.txt'
});
console.log(filePath);
// '/home/user/dir/file.txt'

// name + ext で base を省略
const anotherPath = path.format({
  root: '/',
  name: 'file',
  ext: '.txt'
});
console.log(anotherPath);
// '/file.txt'

プロパティの優先順位に注意してください。

  • dirが指定されるとrootは無視される
  • baseが指定されるとnameextは無視される

パス検証と変換

path.isAbsolute()

path.isAbsolute()はパスが絶対パスかどうかを判定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import path from 'node:path';

// POSIX
console.log(path.isAbsolute('/foo/bar'));    // true
console.log(path.isAbsolute('/baz/..'));     // true
console.log(path.isAbsolute('qux/'));        // false
console.log(path.isAbsolute('.'));           // false

// Windows(path.win32を使用)
console.log(path.win32.isAbsolute('C:\\foo')); // true
console.log(path.win32.isAbsolute('\\\\server')); // true(UNCパス)
console.log(path.win32.isAbsolute('bar\\baz')); // false

path.normalize()

path.normalize()はパス内の...を解決し、連続する区切り文字を正規化します。

1
2
3
4
5
6
7
8
9
import path from 'node:path';

// POSIXでの正規化
console.log(path.normalize('/foo/bar//baz/asdf/quux/..'));
// '/foo/bar/baz/asdf'

// Windowsでの正規化
console.log(path.win32.normalize('C:\\temp\\\\foo\\bar\\..\\'));
// 'C:\\temp\\foo\\'

path.relative()

path.relative()は2つのパス間の相対パスを計算します。

1
2
3
4
5
6
7
8
9
import path from 'node:path';

// POSIXでの相対パス
console.log(path.relative('/data/orandea/test/aaa', '/data/orandea/impl/bbb'));
// '../../impl/bbb'

// Windowsでの相対パス
console.log(path.win32.relative('C:\\orandea\\test\\aaa', 'C:\\orandea\\impl\\bbb'));
// '..\\..\\impl\\bbb'

クロスプラットフォーム対応

path.posixとpath.win32

どのOSで実行しても一貫した結果を得たい場合は、path.posixまたはpath.win32を使用します。

1
2
3
4
5
6
7
8
9
import path from 'node:path';

// どのOSでもPOSIX形式で処理
console.log(path.posix.basename('/tmp/myfile.html'));
// 'myfile.html'

// どのOSでもWindows形式で処理
console.log(path.win32.basename('C:\\temp\\myfile.html'));
// 'myfile.html'

path.posixpath.win32は個別にインポートすることもできます。

1
2
3
4
5
// POSIX専用モジュール
import posix from 'node:path/posix';

// Windows専用モジュール
import win32 from 'node:path/win32';

ユースケース:設定ファイルの一貫したパス処理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import path from 'node:path';

// 設定ファイルで定義されたパス(常にPOSIX形式で保存)
const configuredPath = 'assets/images/logo.png';

// 実行環境に合わせて変換
function toNativePath(posixPath) {
  return posixPath.split(path.posix.sep).join(path.sep);
}

// 逆変換:ネイティブパスをPOSIX形式に
function toPosixPath(nativePath) {
  return nativePath.split(path.sep).join(path.posix.sep);
}

console.log(toNativePath(configuredPath));
// Windows: 'assets\\images\\logo.png'
// POSIX: 'assets/images/logo.png'

URLオブジェクトとの連携

ES Modulesでは__dirnameが使用できないため、import.meta.urlとURL APIを組み合わせてパス操作を行います。

import.meta.urlとfileURLToPath

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { fileURLToPath } from 'node:url';
import path from 'node:path';

// ESMでの__dirname相当の取得
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

console.log(__dirname);
// 例: '/home/user/project/src'

// 相対パスからの絶対パス構築
const configPath = path.join(__dirname, '../config/settings.json');
console.log(configPath);
// 例: '/home/user/project/config/settings.json'

pathToFileURL

ファイルパスをfile:// URLに変換する場合はpathToFileURLを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { pathToFileURL } from 'node:url';
import path from 'node:path';

const filePath = path.join(process.cwd(), 'data', 'sample.json');
const fileUrl = pathToFileURL(filePath);

console.log(fileUrl.href);
// 例: 'file:///home/user/project/data/sample.json'

// 動的インポートに使用
const module = await import(fileUrl);

fileURLToPathの注意点

URLオブジェクトのpathnameを直接使用すると問題が発生する場合があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { fileURLToPath } from 'node:url';

// 正しい方法
console.log(fileURLToPath('file:///C:/path/'));
// 'C:\\path\\' (Windows)

// 間違った方法(pathnameを直接使用)
const url = new URL('file:///C:/path/');
console.log(url.pathname);
// '/C:/path/' (先頭にスラッシュが含まれる)

// スペースを含むパスの処理
console.log(fileURLToPath('file:///hello%20world'));
// '/hello world' (正しくデコードされる)

実践的なユースケース

プロジェクトルートからの相対パス解決

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { fileURLToPath } from 'node:url';
import path from 'node:path';

// プロジェクトルートを基準としたパスを解決するヘルパー
function resolveFromRoot(...segments) {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const projectRoot = path.resolve(__dirname, '..');
  return path.join(projectRoot, ...segments);
}

// 使用例
const configPath = resolveFromRoot('config', 'app.json');
const dataPath = resolveFromRoot('data', 'users.csv');

ファイル拡張子の変更

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import path from 'node:path';

function changeExtension(filePath, newExt) {
  const parsed = path.parse(filePath);
  return path.format({
    dir: parsed.dir,
    name: parsed.name,
    ext: newExt.startsWith('.') ? newExt : `.${newExt}`
  });
}

console.log(changeExtension('/path/to/file.ts', '.js'));
// '/path/to/file.js'

console.log(changeExtension('document.md', 'html'));
// 'document.html'

安全なパス結合(パストラバーサル対策)

 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
import path from 'node:path';

function safeJoin(basePath, userInput) {
  // ユーザー入力からの相対パスを解決
  const resolvedPath = path.resolve(basePath, userInput);
  
  // 結果がベースパス内に収まっているか確認
  const normalizedBase = path.normalize(basePath + path.sep);
  
  if (!resolvedPath.startsWith(normalizedBase)) {
    throw new Error('パストラバーサル攻撃を検出しました');
  }
  
  return resolvedPath;
}

// 使用例
const uploadsDir = '/var/www/uploads';

console.log(safeJoin(uploadsDir, 'images/photo.jpg'));
// '/var/www/uploads/images/photo.jpg'

try {
  safeJoin(uploadsDir, '../../../etc/passwd');
} catch (error) {
  console.error(error.message);
  // 'パストラバーサル攻撃を検出しました'
}

ファイル一覧からの拡張子別分類

 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
import path from 'node:path';

function groupByExtension(files) {
  return files.reduce((acc, file) => {
    const ext = path.extname(file).toLowerCase() || '(no extension)';
    if (!acc[ext]) {
      acc[ext] = [];
    }
    acc[ext].push(path.basename(file));
    return acc;
  }, {});
}

const files = [
  '/docs/readme.md',
  '/src/index.ts',
  '/src/utils.ts',
  '/public/style.css',
  '/public/logo.png',
  '/Makefile'
];

console.log(groupByExtension(files));
// {
//   '.md': ['readme.md'],
//   '.ts': ['index.ts', 'utils.ts'],
//   '.css': ['style.css'],
//   '.png': ['logo.png'],
//   '(no extension)': ['Makefile']
// }

APIリファレンス一覧

API 説明
path.basename(path, suffix?) パスからファイル名を取得
path.dirname(path) パスからディレクトリ名を取得
path.extname(path) パスから拡張子を取得
path.join(...paths) パスセグメントを結合
path.resolve(...paths) 絶対パスを構築
path.relative(from, to) 2つのパス間の相対パスを計算
path.parse(path) パスをオブジェクトに分解
path.format(pathObject) オブジェクトからパスを構築
path.normalize(path) パスを正規化
path.isAbsolute(path) 絶対パスかどうかを判定
path.sep プラットフォーム固有のパス区切り文字
path.delimiter 環境変数PATHの区切り文字
path.posix POSIX形式のpath実装
path.win32 Windows形式のpath実装

まとめ

pathモジュールを使用することで、以下のメリットが得られます。

  • OSごとのパス区切り文字の違いを意識せずにコードを書ける
  • path.posix/path.win32で特定プラットフォーム向けの処理も可能
  • path.parse()/path.format()でパスの分解・構築が容易
  • ES ModulesではfileURLToPathと組み合わせて__dirname相当の機能を実現

文字列の直接連結ではなく、常にpathモジュールのAPIを使用することで、クロスプラットフォームで堅牢なファイルパス処理を実装できます。

参考リンク