JavaScript Uncaught SyntaxError: Cannot use import statement outside a module の原因と解決方法【ブラウザ/Node.js別】

Cannot use import statement outside a moduleとは?ざっくりと3行で
  • Cannot use import statement outside a moduleは、importをモジュール扱いでないファイルで実行したときに出るエラーだ。
  • 実務で圧倒的に多いのは、HTMLのscriptタグにtype=”module”を書き忘れているケース。Node.jsならpackage.jsontype設定が犯人になる。
  • まずそのコードがブラウザで動くのかNode.jsで動くのかを切り分けて、前者ならscriptタグ、後者ならpackage.jsonと拡張子を確認すればいい。
Cannot use import statement outside a module を type=module の追記で解決する4コマ漫画。
①ブラウザのコンソールが赤く染まりimport文が構文エラーになる。②デプロイ太郎がscriptタグにtype=moduleが無いと指摘する。③type=moduleを追記した瞬間エラーが消えて画面が動き出す。④file://で開き直すとCORSで止まりローカルサーバーで解決する。

この4コマで描かれた場面は、モダンなフロントエンド入門で最初に踏みやすい落とし穴です。ブラウザはscript要素にtype="module"が付いていない限り、その中身をクラシックスクリプトとして解析します。クラシックスクリプトの文法にはimport宣言そのものが存在しないため、1行目を読んだ瞬間に構文解析が失敗し、実行が始まる前にファイル全体が破棄されるという結果になります。

構文エラーである点は実務上とても重要です。ランタイムエラーなら例外処理でしのげますが、パース段階で落ちたスクリプトはイベントリスナーの登録も初期化処理も一切走りません。決済ボタンや検索フォームを担当するファイルでこれが起きれば、見た目は正常なのに操作だけが無反応という、問い合わせが来るまで気づきにくい障害になります。

4コマ目のオチにも実務の教訓が詰まっています。モジュール化した途端にfile://で開いたページが動かなくなるのは、モジュールスクリプトがCORSの制約下で読み込まれるためです。ローカル確認は必ずHTTPサーバー経由に切り替える、という運用ルールまでセットで覚えておくと、二度目の足止めを避けられます。

ITKAGYO運営者デプロイ太郎のアイコン画像

コードは1文字も間違っていないのにエラー、というのは本当に焦りますよね。落ち着いて実行環境の設定から見ていきましょう。

Cannot use import statement outside a moduleの基本情報

Cannot use import statement outside a moduleは、ECMAScriptモジュール専用の構文であるimport宣言を、モジュールとして解釈されていないファイルの中に書いたときに投げられる構文エラーです。コードの中身ではなく、そのファイルをどの形式で読むかという環境設定が問われています。

項目内容
エラーメッセージUncaught SyntaxError: Cannot use import statement outside a module(エンジンが生成する本体は SyntaxError: Cannot use import statement outside a module で、先頭の Uncaught はコンソールが未捕捉例外に付ける表示)
発生する言語・環境JavaScript/V8ベースの環境(Chrome、Node.js 12以降)。Firefox・Safariでは同じ状況で別の文言になる
エラーの意味モジュールの外でimport文は使用できません、という意味。ファイルがクラシックスクリプトまたはCommonJSとして解析されている
主な原因HTMLのscriptタグにtype=”module”が無い、package.jsonにtype: moduleが無い、CommonJSとESMの取り違え
解決の基本方針そのファイルを実行しているのがブラウザかNode.jsかを先に確定し、対応する側のモジュール宣言を修正する

なぜCannot use import statement outside a moduleは実行環境ごとに違う顔で現れるのか?

Cannot use import statement outside a moduleを直訳すると、モジュールの外でimport文は使えません、となります。裏を返せば、そのファイルをモジュールとして扱うと宣言してさえいれば解決するわけで、原因はコードではなく宣言の置き場所にあります。宣言の場所は実行環境ごとにまったく別なので、ここでは発生箇所を環境で三分岐させながら追いかけます。

Node.js 20.19 と 22.7 以降は構文検出が既定で有効になり、type未指定の.jsファイルにimportがあると自動的にESMとして再実行されます。同じコードでもNode.jsのバージョン次第でこのエラーが出たり出なかったりするのは、この仕様が理由です。

ブラウザではHTMLのscript要素が唯一の宣言ポイントになり、Node.jsではpackage.jsontypeフィールドとファイル拡張子が判定材料になります。この2系統に加えて、ESMとCommonJSが1つのリポジトリに同居しているケースが第3の発生源です。頻度としては最初のパターンが群を抜いて多く、後半の2つはNode.js側の作業で遭遇します。

HTMLのscriptタグにtype=”module”が無い

入門記事のサンプルをコピーして自作のHTMLに貼り付けたとき、JavaScriptファイルだけを持ってきてscriptタグの属性を写し忘れると、この形になります。ブラウザは属性が無いscriptをクラシックスクリプトとして解析するため、1行目のimportで即座に構文解析が止まります。

<!-- index.html:Chrome で確認 -->
<!DOCTYPE html>
<html lang="ja">
  <body>
    <h1 id="title">読み込み中</h1>
    <!-- 問題の行:type="module" が無いのでクラシックスクリプト扱いになる -->
    <script src="./main.js"></script>
  </body>
</html>

// main.js
import { toYen } from "./format.js"; // ← この行で SyntaxError が発生する
document.getElementById("title").textContent = toYen(1200);

MDNのモジュールガイドは、script要素にtype="module"属性が無い場合はエラーが投げられると明記しています。ここで必要なのはJavaScript側ではなくHTML側の1属性です。読み込まれるformat.jsexportを持っているかどうかも同時に確認しておきましょう。

<!-- index.html:script に type="module" を追加した -->
<script type="module" src="./main.js"></script>

// format.js(名前付きエクスポートを用意しておく)
export function toYen(value) {
  return `${value.toLocaleString("ja-JP")}円`;
}

// main.js(モジュールとして解析されるので import が通る)
import { toYen } from "./format.js";
document.getElementById("title").textContent = toYen(1200); // 1,200円 と表示される

Node.jsでpackage.jsonのtypeがCommonJSのままになっている

ローカルのスクリプトをnode index.jsで実行した瞬間に落ちるパターンです。Node.js公式ドキュメントによれば、最も近い親のpackage.jsontypeフィールドが無い場合、または値がcommonjsの場合、.jsファイルはCommonJSとして扱われます。

// package.json(type を commonjs にしている状態)
{
  "name": "invoice-tool",
  "version": "1.0.0",
  "type": "commonjs"
}

// index.js を node index.js で実行する
import { readFileSync } from "node:fs"; // ← ここで SyntaxError が発生する

const raw = readFileSync("./data.json", "utf8");
console.log(JSON.parse(raw).total);

修正はきわめて単純で、typeの値をmoduleに書き換えるだけです。プロジェクト全体の解釈が切り替わる点には注意が必要で、既存のrequireが残っていれば、そちらは.cjsへ逃がすかimportへ書き換えることになります。typeの変更はファイル単位ではなくディレクトリ単位で効くと覚えておくと事故が減ります。

// package.json:type を module に変更する
{
  "name": "invoice-tool",
  "version": "1.0.0",
  "type": "module"
}

// index.js(Node.js 20 / 22 で確認。JavaScript 側は変更不要)
import { readFileSync } from "node:fs";

const raw = readFileSync("./data.json", "utf8");
console.log(JSON.parse(raw).total);

拡張子が.cjsのファイルにimportを書いている

ビルドスクリプトや設定ファイルを.cjsで作っている現場では、この形が頻出します。Node.js公式ドキュメントは、typeフィールドの値にかかわらず.mjsは常にESM、.cjsは常にCommonJSとして扱われると述べており、拡張子の指定はpackage.jsonより優先されます。

// scripts/build.cjs
import { minify } from "terser"; // ← .cjs は常に CommonJS なので構文エラー

minify("const a = 1;").then((result) => {
  console.log(result.code);
});

ここでの選択肢は2つあります。ファイル名を.mjsへ変えて静的importをそのまま活かすか、CommonJSのまま動的import()で読み込むかです。動的import()はCommonJSからでも呼べるため、既存の巨大なビルドスクリプトに手を入れたくない場面では後者が現実的でしょう。

// scripts/build.cjs:CommonJS のまま ESM を読むなら動的 import() を使う
async function build() {
  const { minify } = await import("terser"); // 関数の中なので await できる
  const result = await minify("const a = 1;");
  console.log(result.code);
}

build();

// もう一つの手:ファイル名を scripts/build.mjs に変えて静的 import に戻す
// import { minify } from "terser";
ITKAGYO運営者デプロイ太郎のアイコン画像

3つのうち最初のパターンが本当に多いです。JavaScriptばかり見ていて、HTMLの1行を見落とす。現場でもよく聞く話です。

Cannot use import statement outside a moduleはブラウザによって文言が変わる?

Cannot use import statement outside a moduleという文言は、実はV8系のエンジンに固有の表現です。同じ原因でもFirefoxやSafariでは別の文章が表示されるため、検索してもヒットしないという事態が起こります。MDNが公開しているエラーリファレンスで確認できた環境だけを、下表にまとめました。

実行環境表示されるエラーメッセージ
V8ベース(Chrome / Node.js)SyntaxError: Cannot use import statement outside a module
Firefox(SpiderMonkey)SyntaxError: import declarations may only appear at top level of a module
Safari(JavaScriptCore)SyntaxError: Unexpected identifier ‘x’. import call expects one or two arguments.

Safariの文言だけ毛色が違うのは、JavaScriptCoreがimportを動的import呼び出しの書き損じとして解釈しようとするからです。引数の数に言及する文章に見えても、直すべき箇所はV8のときとまったく同じで、モジュール宣言の有無になります。なおブラウザのコンソールでは、捕捉されなかった例外の先頭にUncaughtが付いた形で並びます。

JestやExpressでCannot use import statement outside a moduleが出るのはなぜ?

Jestでテストを走らせたときの発生パターン

アプリ本体は問題なく動くのに、npx jestを叩いた瞬間だけCannot use import statement outside a moduleが噴き出すことがあります。Jestのランナーは既定でCommonJSとしてテストファイルを読み込むため、importを含むテストコードやESM専用の依存パッケージがそのまま渡されると解析に失敗します。node_modules配下はデフォルトで変換対象から外れる点も、原因を見えにくくしている要因です。

// __tests__/format.test.js を jest で実行したときに落ちるコード
import { toYen } from "../src/format.js"; // ← CommonJS として読まれると SyntaxError

test("1200 を 1,200円 に整形する", () => {
  expect(toYen(1200)).toBe("1,200円");
});

// 修正1:babel-jest に ESM をトランスパイルさせる(babel.config.cjs)
module.exports = {
  presets: [["@babel/preset-env", { targets: { node: "current" } }]],
};

// 修正2:node_modules 内の ESM パッケージも変換対象に含める(jest.config.cjs)
module.exports = {
  transformIgnorePatterns: ["/node_modules/(?!(nanoid|other-esm-pkg)/)"],
};
Jestで詰まったら、テストファイル単体ではなく依存パッケージがESM専用でないかをpackage.jsonexportsで確認すると切り分けが早く進みます。

Express製サーバーでESM専用パッケージを入れたケース

長く運用してきたExpressのAPIサーバーは、たいていCommonJSで書かれています。そこへ新しいHTTPクライアントやユーティリティを追加したとき、そのパッケージがESM専用に移行済みだと、READMEどおりにimportを書いた行で止まります。requireimportが同じファイルに混在している状態は、解析器から見れば単なる構文違反です。

// server.js(package.json に type が無い=CommonJS のプロジェクト)
const express = require("express");
import fetch from "node-fetch"; // ← ESM 専用パッケージを静的 import して SyntaxError

// 修正A:server.mjs にリネームし、ファイル全体を ESM に統一する
import express from "express";
import fetch from "node-fetch";
const app = express();
app.get("/health", async (req, res) => {
  const r = await fetch("https://example.com");
  res.json({ status: r.status });
});
app.listen(3000);

// 修正B:CommonJS のままにするなら、ハンドラの中で動的 import() する
const express = require("express");
const app = express();
app.get("/health", async (req, res) => {
  const { default: fetch } = await import("node-fetch");
  const r = await fetch("https://example.com");
  res.json({ status: r.status });
});
app.listen(3000);
既存資産が多いサーバーでは、全体をESMへ移行する前に動的import()で局所的に橋渡しする方が安全に着地できます。

Cannot use import statement outside a moduleの周辺で出る別文言にはどう対処する?

import declarations may only appear at top level of a module と表示される場合

Firefoxで表示されるこの文言は、Cannot use import statement outside a moduleと同じ原因のほか、モジュール宣言が済んでいるのにimportをブロックの内側へ書いたときにも現れます。ES Modulesの静的importはファイルのトップレベルにしか置けない決まりで、条件によって読み込みを切り替えたい場合は動的import()へ寄せる必要があります。

// 修正前:条件分岐の内側に静的 import を書いている
if (needsPdf) {
  import { createPdf } from "./pdf.js"; // ← トップレベル以外には置けない
}

// 修正後:動的 import() なら関数やブロックの中から呼べる
if (needsPdf) {
  import("./pdf.js").then(({ createPdf }) => {
    createPdf();
  });
}

TypeScriptをコンパイルした後に出る場合

TypeScript製プロジェクトでは、tsconfig.jsonmodule設定と実行側のモジュール形式が食い違うと、出力された.jsにimport文が残ったままNode.jsへ渡されます。エディタ上は無傷なのにnode dist/index.jsだけが落ちる、という症状が典型です。型定義の解決でつまずいている場合はTypeScript Cannot find moduleのほうが該当します。

// 修正前 tsconfig.json:ESM 形式で出力しているのに CommonJS として実行している
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",  // ← import 文がそのまま出力に残る
    "outDir": "./dist"
  }
}

// 対処1:CommonJS として実行するなら module を commonjs にそろえる
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "outDir": "./dist"
  }
}

// 対処2:ESM のまま実行するなら package.json 側を ESM に合わせる
{
  "name": "app",
  "type": "module"
}

Cannot use import statement outside a moduleはどう切り分けてデバッグする?

Cannot use import statement outside a moduleのデバッグは、コードを読むより先に環境の事実確認から入るのが近道です。スタックトレースは構文エラーの位置しか教えてくれないので、そのファイルがどちらの形式で読み込まれているかを実行時に取得します。Node.jsならコマンド3本、ブラウザならDevToolsのコンソール1行で判定できます。

# 1. Node.js:package.json の type を確認する(未設定なら undefined が返る)
node -p "require('./package.json').type"

# 2. Node.js のバージョンを確認する(構文検出は 20.19 / 22.7 以降が既定)
node -v

# 3. ESM と CommonJS で挙動が変わることを実際に確かめる
node --input-type=module --eval "console.log(typeof require)"    # ESM なら undefined
node --input-type=commonjs --eval "console.log(typeof require)"  # CommonJS なら function

// 4. ブラウザ:読み込まれた script の type を DevTools のコンソールで一覧化する
[...document.querySelectorAll("script")].forEach((s) => {
  console.log(s.src || "(inline)", "type =", s.type || "(classic)");
});
ITKAGYO運営者デプロイ太郎のアイコン画像

4番のワンライナーは地味に便利です。classicと出たscriptが犯人なので、あとは属性を1つ足すだけですよ。

Cannot use import statement outside a moduleを未然に防ぐには?

Cannot use import statement outside a moduleの予防で最も効くのは、モジュール形式を暗黙のままにしないことです。package.jsontypeを必ず明示し、例外的にCommonJSを残すファイルは.cjsで意図を可視化します。あわせてESLintのパーサ設定をファイル種別ごとに分けておけば、エディタ上で赤線が出るため実行前に気づけます。

// eslint.config.js(ESLint 9 系のフラットコンフィグ)
export default [
  {
    files: ["**/*.js"],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: "module", // import / export を前提に解析する
    },
  },
  {
    files: ["**/*.cjs"],
    languageOptions: {
      sourceType: "commonjs", // .cjs だけ CommonJS として解析する
    },
  },
];

// package.json:type を必ず書いて曖昧さを消す
{
  "name": "my-app",
  "type": "module"
}
複数人で触るリポジトリほど、typeの明示と拡張子の使い分けをレビュー観点に加えておく価値があります。

type=”module”を付けるとCannot use import statement outside a moduleの後に何が変わるのか?

Cannot use import statement outside a moduleは属性を1つ足せば消えますが、モジュール化はスクリプトの振る舞いそのものを4点で変えます。第一に、モジュールスクリプトはCORSプロトコルを前提に取得されるため、HTMLをダブルクリックしてfile://で開くとローカルテストでCORSエラーに突き当たります。MDNはサーバー経由でテストする必要があると明記しており、簡易HTTPサーバーの用意が前提条件になります。

第二に、モジュールは自動的に遅延実行されます。defer属性を書く必要はなく、書いても効果はありません。HTMLのパース完了後に実行されるため、document.writeに依存した古い書き方は動かなくなります。第三に、モジュール内のコードは常にstrict modeで動くので、宣言なしの変数代入などがその場で例外になります。

第四が最も見落とされやすい点で、モジュールのトップレベルはグローバルスコープではありません。varで宣言した値もfunctionで定義した関数も外からは見えず、DevToolsのコンソールから呼び出そうとしても未定義になります。HTMLのonclick属性から関数を呼んでいた実装は、モジュール化した途端にJavaScript ReferenceError: is not definedへ姿を変えるので、addEventListenerへの置き換えが必要になります。

ITKAGYO運営者デプロイ太郎のアイコン画像

ここまで押さえれば、モジュール周りの事故はぐっと減ります。焦らず1つずつ確認していきましょう!

よくある質問

Q
ローカルでは動いていたのに、本番サーバーへアップしたら出るようになりました。原因は何ですか?
A

サーバーが返すMIMEタイプを疑ってください。MDNは、.mjsファイルはtext/javascriptなどのJavaScript互換MIMEタイプで配信しないと厳格なMIMEチェックに引っかかると説明しています。多くのサーバーは.jsには正しいMIMEタイプを設定済みですが、.mjsは未設定のことがあります。拡張子を.jsにそろえるか、サーバー設定に.mjsのMIMEタイプを追加するのが確実です。

Q
Jestでテストを流したときだけ発生します。テストコードを書き換えるしかないのでしょうか?
A

テストコードを書き換える必要はありません。babel-jest@babel/preset-envを導入してimportを変換させるか、依存パッケージ側が原因ならtransformIgnorePatternsで該当パッケージを変換対象へ含めます。どちらの設定ファイルも.cjs拡張子で置いておくと、プロジェクトをESM化した後も設定だけは安全に読み込めます。

Q
ESLintやTypeScriptの設定で、実行前に検知できますか?
A

検知できます。ESLintではlanguageOptions.sourceTypeをファイルパターンごとに指定しておくと、CommonJS扱いのファイルにimportを書いた時点でパースエラーが表示されます。TypeScript側ではmodulemoduleResolutionを実行環境にそろえるのが要点で、設定が合っていないと出力後に初めて表面化します。エディタ上の警告を潰しておけば、CIで気づく事態は避けられるでしょう。

Q
Node.jsはどのバージョンからESMを安定して使えるのですか?
A

Node.js公式ドキュメントの変更履歴が答えになります。v13.2.0とv12.17.0でコマンドラインフラグが不要になり、v14.0.0系で実験的機能の警告が消え、v15.3.0・v14.17.0・v12.22.0でモジュール実装が安定化されました。現行のLTSであればESMはStableとして扱われているため、バージョンの古さが原因になるケースは限定的です。むしろtypeフィールドの記述漏れを先に疑うほうが実務的でしょう。

Q
Cannot use import statement outside a module と Cannot find module の違いは何ですか?
A

止まる工程がまったく別です。Cannot use import statement outside a moduleは構文解析の段階で失敗しており、import文の書き方ではなくファイルの解釈形式が問われています。一方のCannot find moduleは構文解析を通過した後、指定されたパスやパッケージを解決できずに失敗した状態で、パスの綴りや依存関係のインストール漏れが焦点になります。前者は環境設定、後者は参照先の実在確認、と切り分けると迷いません。

この記事と一緒に知っておきたいエラー解決

関連エラー この記事との関連
JavaScript SyntaxError: Unexpected token 同じく構文解析の段階でファイル全体が実行されなくなる兄弟エラーです。
TypeScript Cannot find module モジュール形式が正しくてもパス解決に失敗すると出るため、切り分けの対になります。
JavaScript ReferenceError: is not defined type=”module”を付けた後にスコープが分離されて発生する典型的な後続エラーです。
Python ModuleNotFoundError 言語は違っても、モジュールの探索経路と実行環境の設定を疑う点が共通します。
Node.js ERR_REQUIRE_ESM 逆方向の混在、つまりCommonJSからESMをrequireしたときに出るエラーです。

【出典】参考URL

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/import_decl_module_top_level :V8・Firefox・Safari それぞれのエラーメッセージ原文と発生条件
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules :type=”module” の必要性、file:// でのCORS制約、遅延実行、strict mode、モジュールスコープ、.mjs のMIMEタイプ
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type :モジュールスクリプトの遅延処理とCORSプロトコル要件
https://nodejs.org/api/esm.html :Node.jsのモジュール形式判定、構文検出、ESM安定化のバージョン履歴
https://nodejs.org/api/packages.html :package.json の type フィールドと .mjs / .cjs の扱い、構文検出が既定になったバージョン
https://nodejs.org/api/modules.html :require() によるESM読み込みのバージョン履歴
https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Debugging_JavaScript :コンソールに表示される Uncaught 接頭辞の実例

コメント

デプロイ太郎のSNSを見てみる!!