1. ホーム
  2. TypeScript

【TypeScript】tsconfig.json の moduleResolution の使い方|import のファイル解決方法を指定する

Share

import { formatDate } from './utils'import React from 'react' と書いたとき、TypeScript は「どのファイルを読めばいいのか」を自分で探しにいきます。この探し方のルールを決めるのが tsconfig.jsonmoduleResolution です。設定を間違えると、たしかに存在しているはずのパッケージが「Cannot find module」と言われたり、逆に型チェックは通るのに実行時だけ落ちたりします。この記事では、TypeScript がファイルを探す順番と、node10node16nodenextbundlerclassic という値の違いを整理します。

moduleResolution が決めているのは「探し方」

import 文に書く './utils''react' という文字列は、モジュール指定子(module specifier)と呼ばれます。これはファイルパスそのものではありません。拡張子は書かれていませんし、'react' にいたってはパスですらない、ただの名前です。この文字列から実際のファイルを突き止める作業がモジュール解決(module resolution)で、その手順を切り替えるのが moduleResolution オプションです。

ここで大事なのは、TypeScript が探しているのは型情報だという点です。実行時に Node.js やバンドラが読むのは .js ファイルですが、TypeScript が読みたいのは .ts.d.ts です。この2つがずれると、「エディタでは型が付いているのに実行すると Module not found」あるいはその逆、という噛み合わない状態が生まれます。moduleResolution に「実行環境と同じルール」を選ぶことが、そのずれを防ぐいちばんの方法になります。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    // import 先のファイルを Node.js と同じルールで探す
    "moduleResolution": "NodeNext",
    "strict": true
  },
  "include": ["src"]
}

値の大文字・小文字は区別されないので、"NodeNext""nodenext" のどちらで書いても同じ意味です。公式ドキュメントは小文字表記、tsc --init が生成する設定は大文字始まりの表記を使っています。

相対パスはどの順番で探されるのか

'./''../' で始まる指定子は相対インポートと呼ばれ、書いたファイルの位置を起点に解決されます。長らく標準だった node10 のルールでは、拡張子が省略されていると TypeScript が候補を順番に試していきます。

拡張子を補う順番

src/index.ts の中に import { formatDate } from './utils' と書いた場合、次の順で探されます。最初に見つかったものが採用され、そこで探索は終わります。

‘./utils’ が探される順番
src/utils.ts
src/utils.tsx
src/utils.d.ts
src/utils/package.json  ← "types" フィールドがあればそのファイルへ
src/utils/index.ts
src/utils/index.tsx
src/utils/index.d.ts

.ts.d.ts より先に来るのは、ソースがあるならソースを優先するためです。allowJs を有効にしている場合は、この列に .js.jsx が加わります。.tsx が候補に入るのは jsx オプションを有効にしているときで、React を扱うプロジェクトではこれが効いています。

ディレクトリを指すと index へ落ちる

上の一覧の後半にあるとおり、./utils がディレクトリだった場合は ./utils/index.ts が読まれます。src/components/Button/index.ts を用意して import { Button } from './components/Button' と書くお馴染みの構成は、このフォールバックに支えられています。

ただし、この「拡張子を省ける」「index に落ちる」という便利さは node10bundler の性質であって、モジュール解決の普遍的なルールではありません。後述する node16 / nodenext で ES Modules として扱われるファイルでは、どちらも使えなくなります。

非相対パスは node_modules を上へたどる

'react''@tanstack/react-query' のように ./ で始まらない指定子は、非相対インポートと呼ばれます。こちらはインポート元のファイルがあるディレクトリから node_modules を探し、見つからなければ親ディレクトリへ、さらにその親へと、ルートに着くまでさかのぼります。

src/app/page.ts から ‘react’ を探すとき
src/app/node_modules/react
src/app/node_modules/@types/react
src/node_modules/react
src/node_modules/@types/react
node_modules/react
node_modules/@types/react
(見つからなければ、さらに親ディレクトリへ)

モノレポで「ワークスペース直下には無いのに、リポジトリのルートに置いた依存が読めてしまう」ことがあるのは、この遡上のためです。逆に言えば、深い階層に予期しない node_modules があると、そちらが先に見つかって古いバージョンの型が使われる、という事故も起こります。

package.json の types / typings フィールド

node_modules/foo というディレクトリに行き着いたあと、TypeScript が最初に読むのはそのパッケージの package.json です。ここに types(または古い別名の typings)フィールドがあれば、指定された .d.ts がそのパッケージの型定義として採用されます。

node_modules/foo/package.json
{
  "name": "foo",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

types が無い場合は、main が指すファイルの拡張子を .d.ts に読み替えたもの、それも無ければ index.d.ts が試されます。自作パッケージを社内で配布していて型が効かないときは、たいてい types の書き忘れか、指しているパスがビルド成果物とずれていることが原因です。

型定義を持たないパッケージと @types

パッケージ本体に型定義が同梱されていない場合に備えて、TypeScript は同じ階層の node_modules/@types/foo も探します。これが DefinitelyTyped から配布される @types/express@types/node といったパッケージの置き場所です。スコープ付きパッケージは @/ がハイフンに畳まれ、@babel/core の型は @types/babel__core になります。

パッケージ自身が types を持っているなら、そちらが優先されます。近年のライブラリはほとんど型定義を同梱しているので、@types/ を追加でインストールする必要があるのは、古いパッケージや JavaScript のみで書かれたパッケージを使うときに限られます。

指定できる値と、その違い

TypeScript 5 系で選べる主な値は次の5つです。nodenode10 の旧名で、TypeScript 5.0 で node10 という分かりやすい名前が追加されました。どちらを書いても動作は同じです。

想定する環境exports フィールド拡張子の省略
node10(旧 node古い Node.js の CommonJS見ないできる
node16Node.js 16 以降の ESM / CJS 両対応見るESM では不可
nodenext最新の Node.js に追随する見るESM では不可
bundlerVite・webpack などのバンドラ見るできる
classic旧仕様(互換のためだけに残る)見ないできる

node10 は exports を無視する

node10 は、Node.js が CommonJS しか持っていなかった時代の require() の挙動を再現したものです。ここまで説明してきた「拡張子を補う」「index に落ちる」「node_modules を上へたどる」という手順は、すべてこの方式のものです。

問題は、package.jsonexports フィールドをまったく見ないことです。exports は「このパッケージの外から読んでよい入口はここだけ」と宣言する仕組みで、現在の Node.js もバンドラもこれを尊重します。node10 はそれを無視してディレクトリ内のファイルを直接探しにいくため、実行時には読み込めないファイルを型チェックだけ通してしまうことがあります。

src/index.ts(moduleResolution: node10 のとき)
// exports で公開されていない内部ファイルでも、
// node10 なら node_modules/foo/dist/internal.d.ts を見つけて通してしまう
import { secret } from 'foo/dist/internal';

// 実行時: Error [ERR_PACKAGE_PATH_NOT_EXPORTED]

逆のパターンもあります。exports で「foo/utils という名前で dist/u.js を公開する」と宣言しているパッケージは、ディレクトリ構成上は foo/utils が存在しません。node10 はサブパスの別名を解釈できないので、正しい書き方をしているのに TS2307 になります。新規のプロジェクトで node10 を選ぶ理由は、もうほとんどありません。

node16 と nodenext は Node.js の判定に従う

この2つは、現在の Node.js がやっていることをそのまま真似します。まずファイルごとに ES Modules なのか CommonJS なのかを判定し、その結果によって解決ルールを切り替えるのが特徴です。判定材料は拡張子と、いちばん近い package.jsontype フィールドです。

ファイル判定
.mts / .mjs常に ES Modules
.cts / .cjs常に CommonJS
.ts / .js最も近い package.json"type""module" なら ES Modules、それ以外は CommonJS

ES Modules と判定されたファイルでは、Node.js の ESM が拡張子の省略を認めていないため、TypeScript も相対インポートに拡張子を要求します。しかも書くべきなのは出力後のファイル名、つまり ./utils.ts ではなく ./utils.js です。TypeScript は指定子を書き換えないので、コンパイル後にそのまま Node.js が読める形にしておく必要があるからです。

src/index.ts(”type”: “module” のプロジェクト)
// error TS2835: Relative import paths need explicit file extensions
// in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
// Did you mean './utils.js'?
import { formatDate } from './utils';

// 正しい書き方。参照されるのは utils.ts だが、書くのは .js
import { formatDate } from './utils.js';

初見ではぎょっとする書き方ですが、「実行されるコードに書かれているとおりの文字列を、ソースにも書く」と考えれば筋は通っています。index へのフォールバックも同様に効かなくなるので、./components/Button ではなく ./components/Button/index.js と書きます。なお、CommonJS と判定されたファイルではこの制約はかからず、従来どおり拡張子を省略できます。

node16nodenext の関係は、target における ES2022ESNext の関係に似ています。node16 は Node.js 16 相当の挙動に固定され、nodenext は TypeScript のバージョンが上がるたびに最新の Node.js の挙動へ追随します。挙動を固定したいなら node16、最新に合わせたいなら nodenext を選びます。

bundler はバンドラの緩さに合わせる

TypeScript 5.0 で追加された bundler は、Vite・webpack・esbuild などのバンドラが実際にやっている解決を再現する値です。ひとことで言えば「exports は尊重するが、拡張子の省略と index へのフォールバックは許す」という組み合わせで、node16 の厳しさと node10 の書き心地のいいところを取ったものになっています。

バンドラは import 文をそのまま実行環境に渡すわけではなく、自分で解決して1つのファイルにまとめてしまいます。だから拡張子が無くても困りませんし、exports の宣言も読めます。フロントエンドのプロジェクトでは、この値がいちばん現実に近い設定です。

tsconfig.json(Vite などのバンドラ構成)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    // 変換はバンドラが行うので、tsc は型チェックだけ担当する
    "noEmit": true,
    "strict": true
  },
  "include": ["src"]
}

ひとつ注意点があります。bundlermodulecommonjs のときには使えません。"module": "ESNext""module": "Preserve" のように import 文を残す設定と組み合わせる必要があり、噛み合っていないと「Option ‘bundler’ can only be used when ‘module’ is set to ‘preserve’ or to ‘es2015’ or later.」というエラー(TS5095)が出ます。

classic を選ぶ場面はない

classic は、Node.js の node_modules という仕組みが一般化する前に TypeScript が持っていた独自ルールです。非相対インポートに対して node_modules をまったく見ず、インポート元のディレクトリから親へ向かって同名の .ts / .d.ts を探すだけ、という単純な動きをします。

当然、npm でインストールしたパッケージは何ひとつ見つかりません。それでもこの値が問題になるのは、後述するとおり設定を書かないと既定でこれが選ばれる場合があるからです。意図して選ぶ理由はないので、既存のプロジェクトで classic が効いていたら設定漏れを疑ってください。

module オプションが既定値を決めている

moduleResolution を書かなかった場合、TypeScript は module の値から適切な既定値を推測します。TypeScript 5 系では次の対応になっています。

module の値moduleResolution の既定
CommonJSNode10
Node16Node16
NodeNextNodeNext
PreserveBundler
上記以外(ESNext / ES2020 など)Classic

気をつけたいのは最後の行です。"module": "ESNext" だけを書いて moduleResolution を省略すると、node_modules を見ない Classic が選ばれ、すべての npm パッケージが「見つからない」状態になります。バンドラ構成でこの組み合わせを使うなら、"moduleResolution": "Bundler" を必ず明示してください。

Node16 / NodeNext / Preserve のように、module 側を選べば moduleResolution も自動でそろう値もあります。この場合は書かなくても正しく動きますが、設定ファイルを読む人のために明示しておくほうが親切でしょう。いま実際に効いている値は npx tsc --showConfig で確認できます。

Cannot find module と言われたときに見る場所

モジュール解決でいちばんよく遭遇するのが TS2307、Cannot find module 'foo' or its corresponding type declarations. です。メッセージが「モジュールか、それに対応する型定義が見つからない」と2つ並べているとおり、原因も「本当に無い」場合と「あるのに探し方が合っていない」場合に分かれます。

exports に types 条件が無いパッケージ

node16bundler に切り替えた途端、それまで読めていたパッケージが TS2307 になることがあります。多くはパッケージ側の exports に型定義の条件が書かれていないケースです。exports を書いた時点で、TypeScript はトップレベルの types フィールドではなく exports の中を見にいくようになるため、そこに types 条件が無いと型定義にたどり着けません。

node_modules/foo/package.json(型が見つからない例)
{
  "name": "foo",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

正しくは、次のように types 条件をいちばん先頭に置きます。条件は上から順に評価されるため、importrequire より後ろに書くと読まれません。

node_modules/foo/package.json(正しい例)
{
  "name": "foo",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}

自分が公開しているパッケージならこう直せばよいのですが、他人のパッケージだと修正を待つしかありません。応急処置としては、paths でそのパッケージ名から型定義ファイルへ直接マッピングを張る方法があります。pathsbaseUrl の詳細は別の記事で扱っていますが、モジュール解決の結果を上書きする最後の手段として使える、とだけ覚えておくと役に立ちます。

tsconfig.json(応急処置)
{
  "compilerOptions": {
    "moduleResolution": "Bundler",
    "paths": {
      // exports に types 条件が無いパッケージの型を直接指す
      "foo": ["./node_modules/foo/dist/index.d.ts"]
    }
  }
}

型定義そのものが存在しない

パッケージが JavaScript だけで書かれていて、@types も公開されていない場合は、探し方をどう変えても見つかりません。この場合は自分で宣言を書きます。プロジェクト内に .d.ts ファイルを1つ用意し、モジュール名を宣言しておけば TS2307 は消えます。

src/types/legacy-lib.d.ts
// 型は any 扱いになるが、import できるようになる
declare module 'legacy-lib';

// 分かっている分だけ書いてもよい
declare module 'legacy-lib' {
  export function greet(name: string): string;
}

このファイルが tsconfig.jsoninclude に入っていることを確認してください。"include": ["src"] のようにディレクトリ単位で指定していれば自動的に拾われますが、ファイルを列挙している設定では対象から漏れることがあります。

エディタと tsc で結果が違う

VS Code では赤線が出ないのに npx tsc --noEmit では落ちる(あるいはその逆)というときは、両者が別の tsconfig.json を見ている可能性が高いです。モノレポでルートと各パッケージに設定がある構成では特に起こりがちで、エディタが開いているファイルに対してどの設定を使っているかは、VS Code のコマンドパレットから「TypeScript: Go to Project Configuration」で確認できます。

エディタが使う TypeScript のバージョンがワークスペースのものと違うケースもあります。bundler は TypeScript 5.0 で追加された値なので、古いバージョンが動いていると値そのものが認識されません。

traceResolution で探索の過程を覗く

推測で設定をいじるより早いのが、TypeScript に探索の過程を喋らせることです。--traceResolution を付けて実行すると、どのファイルを試して、なぜ失敗したかが全部出力されます。

ターミナル
# 全モジュールの解決過程を出力する(かなり長い)
npx tsc --noEmit --traceResolution

# 気になるパッケージ名で絞り込む
npx tsc --noEmit --traceResolution | grep -A 20 "Resolving module 'foo'"

出力は次のような形で、1つのモジュールにつき「どのルールで探しているか」「どのファイルを試したか」「結果どこに決まったか」が順に並びます。

traceResolution の出力(抜粋)
======== Resolving module 'foo' from '/app/src/index.ts'. ========
Module resolution kind is set to 'Bundler'.
Resolving in CJS mode with conditions 'import', 'types'.
File '/app/node_modules/foo/package.json' exists according to earlier cached lookups.
Entering conditional exports.
Saw non-matching condition 'require'.
Failed to resolve under condition 'types'.
Exiting conditional exports.
======== Module name 'foo' was not resolved. ========

この例なら「exports の条件分岐に入ったが types 条件で解決できなかった」と読めるので、原因が先ほどの types 条件の欠落だと一目で分かります。逆に node10 なら「File ‘/app/node_modules/foo/index.d.ts’ does not exist.」のような行が延々と並び、どの候補まで試したかが見えます。エラーメッセージだけを眺めて設定を当てずっぽうに変えるより、まずこの出力を見るほうが確実です。

どの値を選ぶべきか

判断の軸は「そのコードを最終的に誰が読み込むか」の一点です。Vite や webpack でバンドルするフロントエンドなら bundler、tsc でビルドして Node.js に直接実行させるなら node16nodenext を選びます。Next.js のようにフレームワークが tsconfig.json を生成してくれる場合は、そこに書かれた値をそのまま使うのが安全です。

node10 は、既存のプロジェクトを壊さないために残す選択肢と考えてください。ライブラリを npm で公開するなら、利用者がどの値を使っていても壊れないように exportstypes 条件を書き、公開前に arethetypeswrong のようなツールで確認しておくと安心です。

まとめ

moduleResolution は、import に書いた文字列から実際のファイルと型定義を突き止める手順を決めるオプションです。相対パスなら .ts.tsx.d.ts の順に拡張子が補われ、ディレクトリなら index に落ちます。非相対パスなら node_modules を親へたどりながら、package.jsontypes フィールドや @types パッケージを探しにいきます。

値の違いは、exports フィールドを見るかどうかと、拡張子を省略できるかどうかに集約されます。node10exports を見ない古い方式、node16nodenext は Node.js の ESM / CJS 判定に従って ES Modules では拡張子付きの ./utils.js を要求する厳格な方式、bundler はその中間でバンドラの挙動に合わせたものです。classic は使いません。

書かなかったときの既定値は module から決まり、"module": "ESNext" のときは Classic になってしまうので明示が必要です。TS2307 で詰まったら、まず npx tsc --traceResolution で探索の過程を確認し、exportstypes 条件が欠けていないかを見るのが近道になります。

参考ページ