動かざることバグの如し

近づきたいよ 君の理想に

テスト書くならtsconfigのpathsじゃなくてsubpath imports使おうよ

環境

  • nodejs v24
  • typescript v5

まずはpathsとsubpath importsをそれぞれ押さえてみる。

paths

pathsbaseUrltsconfig.json を基準に、TypeScript の importrequire の解決先を再マップする機能だ。 ただしこれはあくまで TypeScript 側の解決規則であって、実行時の Node.js にそのまま伝わるわけではない。

設定例

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts"]
}
// src/lib/math.ts
export function add(a: number, b: number) {
  return a + b;
}
// src/index.ts
import { add } from "@/lib/math";

console.log(add(1, 2));
  • メリットは、TypeScript に対して短い import 名の解決先を宣言できるので、型チェック用のエイリアスとして扱いやすいことだ
  • デメリットは、tsc@/lib/math のような specifier を出力時に直さないので、Node.js で動かすには bundler、loader、post-build 変換など別の仕組みが必要になることだ

subpath imports

Node.js の subpath imports は package.jsonimports フィールドで定義する private mapping で、同じ package の内側からだけ使え、キーは必ず # で始まる。 TypeScript v5 では node16nodenextbundler の解決モードでこの imports を参照するから、Node と TypeScript の解決規則を package.json に寄せやすい。

設定例

// package.json
{
  "name": "sample-app",
  "type": "module",
  "imports": {
    "#lib/*.js": "./dist/lib/*.js"
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "resolvePackageJsonImports": true
  },
  "include": ["src/**/*.ts"]
}
// src/lib/math.ts
export function add(a: number, b: number) {
  return a + b;
}
// src/index.ts
import { add } from "#lib/math.js";

console.log(add(1, 2));
  • メリットは、Node.js 自身が imports を理解するので、実行時の解決と TypeScript の解決を揃えやすいことだ
  • また importsexports と同様のルールで解決され、条件分岐を使え、しかも exports と違って外部 package へのマッピングも許されるのもメリット
  • デメリットは、package の外からは使えず、specifier が #... 形式に制限されることだ
  • さらに Node.js の import 解決は拡張子探索をせず、subpath pattern も特別な拡張子補完をしないから、#lib/math.js のように .js まで含めて書く前提で設計したほうが安全だ

テスト時のモッキングで差が出る

モックする際は subpath imports のほうが有利な場合がある。

paths を使っている場合、テスト時に依存を差し替えたいときに以下のような問題が起きる。

// src/services/calculator.ts
import { add } from "@/lib/math";

export function calculate(x: number, y: number) {
  return add(x, y) * 2;
}

テストで add をモックしようとしても、@/lib/math という文字列そのものを解決する必要があるため、テストランナーやローダーの設定が複雑になる。Jest なら moduleNameMapper、Vitest なら resolve.alias の設定が必要だ。

一方で subpath imports を使うと、imports フィールドに条件分岐を書ける。

// package.json
{
  "imports": {
    "#lib/*.js": {
      "default": "./dist/lib/*.js",
      "test": "./dist/lib/__mocks__/*.js"
    }
  }
}

このように設定すると、NODE_ENV=test などで実行したときは自動的に __mocks__ ディレクトリの実装を参照するようになる。TypeScript も同じ package.json を参照するため、型チェックと実行時の動作が自然に一致する。

// src/lib/__mocks__/math.ts
export function add(a: number, b: number) {
  return 100; // テスト用の固定値
}

ビルドやバンドラーの設定をいじらずに、Node.js 標準の機能だけでモック切り替えが実現できるのは大きな利点だ。

参考リンク