TypeScript で `.js` 拡張子を付けないと NodeNext の import が解決できない
moduleResolution: node16 / nodenext では ESM 仕様に従い相対 import に拡張子が必須。
元ファイルが .ts でも import 側には .js と書くのが正解。
バンドラ前提なら bundler に切り替えるのが楽。
公開:
要約
Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext' というエラーは、ESM 仕様準拠の挙動を TypeScript がそのまま追従しているもの。import './foo' を import './foo.js' に書き換えれば解決する。
違和感があるが、これが正しい。
よくある原因
tsconfig.jsonで"moduleResolution": "node16"または"nodenext"にしているpackage.jsonの"type": "module"で完全 ESM プロジェクトになっている- 旧来の
moduleResolution: nodeの慣れで、拡張子なしの import を多用している - パスエイリアス(
@/foo)には警告が出ないので、相対 import だけ修正漏れしている
解決策
1. 相対 import に .js を付ける
// 修正前
import { foo } from "./foo";
import { bar } from "../utils/bar";
// 修正後
import { foo } from "./foo.js";
import { bar } from "../utils/bar.js";元ファイルが .ts でも import パスは .js。
TypeScript はコンパイル後のパスとしてこれを解釈する。
違和感があるが、Node.js の ESM 仕様が拡張子必須なのでそれに従う形になっている。
詳細は TypeScript Modules Reference 公式ドキュメント を参照。
2. バンドラ前提なら bundler に切り替える
Next.js / Vite / esbuild などバンドラがパス解決を担うプロジェクトでは:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
}
}bundler は拡張子を要求しない。
フロントエンド寄りのプロジェクトはこちらの方が無難。
各値の選び方は tsconfig moduleResolution 公式 にまとまっている。
3. 検証専用なら .ts 拡張子を許す
noEmit: true で型検査のみのプロジェクトでは:
{
"compilerOptions": {
"moduleResolution": "node16",
"allowImportingTsExtensions": true,
"noEmit": true
}
}これで import "./foo.ts" のように .ts 拡張子を書けるようになる。
ただし noEmit 必須なので、ビルド成果物を出すプロジェクトでは使えない。
4. 既存コードの一括変換
VS Code の正規表現置換、または ts-add-js-extension のようなツールで from "./foo" → from "./foo.js" の一括変換が可能。
ファイル数が多いプロジェクトの移行時に有効。