プロキシ会社がなぜこのような記事を書いているのか、簡単に説明します。当社は Geonode ですが、お客様から報告される JSON エラーの中で最も多いのが、取得したデータに対する「Unexpected token '<'」というエラーです。これは、レスポンスが HTML(エラーページ、ログインリダイレクト、またはブロックページなど)であり、パーサーが「JSON として受け取られなかった」と正確に報告していることを意味します。 コードを変更する前に、受信したデータの最初の200文字をログに記録してください。この記事のほぼすべての説明は、ファイルが実際にJSONであることを前提としていますが、その前提こそが最も頻繁に誤りとなるのです。
Node.js:ディスクからの読み込み
3つのアプローチがあり、そのうちの1つが最新の解決策です。
**fs/promises
、標準的な方法:**
import { readFile } from "node:fs/promises";
const raw = await readFile("./data.json", "utf8");
const data = JSON.parse(raw);
Nodeのドキュメントでは、encoding引数について具体的に説明されており、これは重要です。encodingを指定しない場合、readFile
は「ファイルの内容を含む<Buffer>
オブジェクトで解決されるPromiseを返す」のに対し、指定した場合は「<string>
で解決される」ことになります。JSON.parse
は、Bufferを文字列に強制変換して受け入れるため、エンコーディングを省略しても通常は問題なく動作し、不必要に余分な変換を行ってしまいます。"utf8"
を指定してください。
また、signal
オプションを通じてAbortSignal
もサポートしており、「進行中のreadFile
操作を中止できる」— 読み取りがキャンセルされる可能性のあるリクエストの一部である場合に便利です。
起動時のコード向け:同期処理
import { readFileSync } from "node:fs";
const config = JSON.parse(readFileSync("./config.json", "utf8"));
サーバーがサービスを開始する前であれば、ブロック処理は問題ありません。しかし、リクエストハンドラ内では、他のすべての接続に対してイベントループを停止させてしまうため、問題となります。この区別こそが、このルールの核心です。
**require
(CommonJS のみ):**
const data = require("./data.json");
簡潔であり、人々が忘れがちな2つの特性を持っています。キャッシュ機能があるため、同じパスのrequire
を2回呼び出しても、ファイルを再読み込みせずに同じオブジェクトが返されます。つまり、実行時にファイルを編集しても影響はありません。また、ESモジュールでは利用できません。
属性のインポート:標準的な方法
多くの人がまだ移行していない構文であり、新しいコードではこれを使用すべきです。
import data from "./data.json" with { type: "json" };
あるいは動的に:
const data = await import("./data.json", { with: { type: "json" } });
MDN では、これを Baseline 2025 として記録しており、2025年4月以降、最新のブラウザで利用可能となり、Node や Deno などの非ブラウザランタイムも、JSON モジュールに関するブラウザのセマンティクスに準拠するようになっています。
type: "json"
属性は単なる装飾ではありません。MDNによると、この属性は「モジュールがapplication/json
というMIMEタイプで配信されていることを検証する」ものであり、ファイルが「application/json
以外のMIMEタイプで配信された場合、インポートは失敗する」と説明されています。
この属性がオプションではなく必須である理由を説明しているセキュリティ上の根拠は、引用する価値があります:
何らかの理由(例:サーバーが乗っ取られたり、偽のサーバーであったりする場合)で、サーバーのレスポンスにおけるメディアタイプが
text/javascript
(JavaScript ソース用)に設定されている場合、そのファイルはコードとして解析され、実行されてしまいます。 もしその「JSON」ファイルに実際に悪意のあるコードが含まれている場合、import
宣言によって意図せず外部コードが実行され、深刻な脅威となる可能性があります。
移行に関する注意点として、以前の提案では with
の代わりに assert
キーワードが使用されていました。MDN はこの変更を互換性を破る変更として挙げており、assert
を使用する実装は「もはやサポートされません」。古いコードやチュートリアルで assert { type: "json" }
が見つかった場合は、更新する必要があります。
ブラウザ内:JSONの取得
最も一般的なケースですが、落とし穴もあります。
const res = await fetch("/data.json");
if (!res.ok) throw new Error(`HTTP ${res.status} from ${res.url}`);
const data = await res.json();
res.okのチェックは必須です。これを省略することが、この記事の冒頭で述べたエラーの直接的な原因となります。fetchはHTTPエラーステータス(403、404、500など)を理由にリクエストを拒否せず、これらすべてが通常通り処理されます。 .json() を呼び出すと、エラーページの解析が試みられ、JSONとは全く関係のない<文字に関する構文エラーが発生します。
有用なエラーメッセージを出力するバージョンはこちら:
async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
const type = res.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
const body = await res.text();
throw new Error(`Expected JSON, got ${type}: ${body.slice(0, 200)}`);
}
return res.json();
}
2行のチェックを追加するだけで、意味不明な解析エラーを、ステータス、コンテンツタイプ、実際に受信された内容を示すメッセージに変換できます。
また、Response.json()はリバイバー関数を受け付けない点にも注意してください。日付の変換や大きな整数の処理など、リバイバー関数が必要な場合は、res.text()の後にJSON.parseを指定してください。リバイバー関数と精度の問題については、JSON.parseのガイドで解説しています。
ブラウザ内:ユーザーが選択したファイル
ユーザーのマシンから選択されたファイルについては、File API を使用します。
<input type="file" id="picker" accept="application/json">
document.getElementById("picker").addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
console.log(data);
} catch (err) {
console.error(`Could not parse ${file.name}: ${err.message}`);
}
});
File.text()
は、内容を文字列として返すプロミスを返します。これは、イベントハンドラを伴う旧式の FileReader
よりもかなりすっきりしています。非常に大きなファイルで進行状況イベントを取得したい場合は、依然として FileReader
が必要です。
覚えておくべき点が2つあります。ブラウザは任意のローカルパスを読み取ることができません。ユーザーがファイルを選択する必要があり、これは回避すべき制限ではなく、意図的なセキュリティ上の境界です。また、.json
拡張子は内容について何も保証しないため、try
/catch
による処理が実際の作業を担っています。
ドラッグ&ドロップでは、event.dataTransfer.files
から取得されるのと同じ File
オブジェクトが使用されます。
何かを教えてくれるエラー処理
5分で解決できる問題と1時間かかる問題を分ける習慣。
function parseJson(text, source) {
try {
return JSON.parse(text);
} catch (err) {
throw new Error(
`Failed to parse JSON from ${source}: ${err.message}. ` +
`First 200 chars: ${text.slice(0, 200)}`
);
}
}
重要なのは「切り取り」だ。 JSON.parse エラーメッセージには位置と文字が示されるが、実際の入力内容こそが理由を教えてくれる。 3つのシグネチャでほとんどのケースをカバーできます:
Unexpected token '<' — コンテンツはHTMLです。エラーページ、ログイン後のリダイレクト、またはディレクトリ一覧など。
Unexpected end of JSON input — 入力が空か、切り詰められています。204レスポンス、書き込みに失敗したファイル、またはawaitを忘れたフェッチなど。
Unexpected token '}'(妥当な位置にある場合) — 明らかに不正な形式のJSON。多くの場合、末尾のコンマが原因です。JavaScriptでは許可されていますが、JSONでは禁止されています。
ファイルの読み取りについては、読み取りの失敗と解析の失敗を区別してください。ENOENTはファイルが存在しないことを意味し、これは不正な内容とは別の問題であるため、別のメッセージを表示する必要があります。
読み込んだ内容の検証
構文解析に成功しました。これは、構文が有効であったことを示すだけで、データがコードが期待する形式であるかどうかについては何も示していません。この2つの間のギャップこそが、本番環境での障害の驚くほど多くの原因となっているのです。
構文解析と検証は別々のステップです。 ``JSON.parse`
は、キーにタイプミスがある ``{"user": {"nmae": "Ada"}}
や、数値が期待されているにもかかわらず ``"n/a"
という文字列を含む ``price
` フィールドであっても、何の問題もなく返してしまいます。その結果、実際の問題から数関数離れた下流のどこかでコードが失敗し、原因ではなく症状を指摘するエラーが発生することになります。
自分の制御が及ばないものについては、スキーマに対して検証を行ってください。 これをうまく処理するライブラリはいくつかあり、どれを選んでもパターンは同じです:
const Config = z.object({
port: z.number().int().min(1).max(65535),
host: z.string(),
retries: z.number().int().default(3),
features: z.array(z.string()).optional(),
});
const config = Config.parse(JSON.parse(await readFile("./config.json", "utf8")));
これで、エラーにはフィールド名、期待される型、および実際に検出された内容が明示されます。これが、5分で修正できるか、それとも半日かかるかの違いとなります。
単純なケースでは、数個のアサーションを追加してもコストはかかりません:
const data = JSON.parse(raw);
if (!Array.isArray(data.items)) throw new Error("items must be an array");
if (data.items.length === 0) throw new Error("items is empty — check the source");
この2つ目のチェックは、見た目以上に価値があります。空の配列は有効なJSONであり、問題なくパースされますが、多くの場合、正当な結果というよりは症状に過ぎません。たとえば、フィルタの設定ミスで何も返さなかったAPIや、変更されたページに対してスクレイピングが成功してしまった場合などが挙げられます。
自分で生成していない数値には懐疑的になりましょう。 JSONの数値はJavaScriptのdouble型に変換されるため、Number.MAX_SAFE_INTEGER
を超える整数は、どの段階でもエラーを出さずに静かに精度を失います。 識別子が最もよく犠牲になります。2つの異なるデータベースレコードが、同じ値としてパースされてしまうことがあります。フィールドが数量ではなく識別子である場合は、JSON内では文字列として扱うべきです。また、生成元を制御できない場合は、reviverのcontext.source
引数を使用することで、元の数字を取得できます。
そして、境界で一度だけ検証を行うこと。 データがプログラムに入ってくる時点でその構造をチェックしておけば、下流のすべての処理でそのデータが正しいと仮定できます。20カ所で予防的にチェックを行うことは、20カ所を更新する必要があることを意味し、契約が明文化されている単一の拠点が失われてしまいます。
大容量ファイル `
JSON.parse
` は同期処理であり、ドキュメント全体をメモリに読み込む必要があります。ファイルが大きくなるにつれて、これら両方が問題となります。
大まかな指針。 1メガバイト未満の場合は、特に気にする必要はありません。1~10メガバイトの場合は、測定を行ってください。特にブラウザのメインスレッドでは、解析処理がレンダリングをブロックし、目に見えるカクつきを引き起こすため注意が必要です。 10メガバイト以上、あるいは利用可能なメモリの約10分の1を超える場合は、別の方法を検討してください。
メインスレッドから切り離す。 ブラウザでは、Web Worker を使用することで、インターフェースをフリーズさせることなく解析を行うことができます。Node.js では、ワーカースレッドがイベントループに対して同様の役割を果たします。
改行区切りのJSONを使用してください。 これは回避策というよりは、構造的な解決策です。1行に1つのJSONドキュメントを記述することで、任意のサイズのファイルを一定のメモリ使用量で処理でき、各行が独立して解析され、ファイルが途切れていても完全なレコードをすべて取得できます:
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const rl = createInterface({ input: createReadStream("./data.jsonl") });
for await (const line of rl) {
if (line.trim()) handle(JSON.parse(line));
}
フォーマットを制御できる場合、これは時間の経過とともに追加されていくあらゆるデータにとってより優れた設計です。また、これが、クラッシュした収集ジョブが解析不可能なファイルではなく、使用可能なファイルを残す理由でもあります。
フォーマットが変更できない単一の大きな配列である場合は、ストリーミングパーサーを使用してください。 いくつかのライブラリは、ツリー全体を構築するのではなく、値が到着するたびにそれを出力します。
あるいは、要求する量を減らす。ページネーション、フィールドの選択、より限定的なエンドポイント。これはほとんどの場合、正しい解決策ですが、APIの管理者と協議する必要があるため、ほとんどの場合見過ごされてしまいます。
JSONの書き出し
通常、次に聞かれる質問なので、簡単にその逆のケースについて説明します。
import { writeFile } from "node:fs/promises";
await writeFile("./out.json", JSON.stringify(data, null, 2), "utf8");
null, 2
引数を指定すると、インデント付きの出力が生成されます。これは見た目以上に重要です。人間が読むことや、バージョン管理でdiffを行う可能性のあるファイルはフォーマットすべきですが、送信されるファイルはフォーマットすべきではありません。
JSON.stringify
では、以下の3つの要素が保持されず、エラーではなく黙ってデータが失われます。undefined
の値や関数はオブジェクトから完全に削除され、配列内では null
となります。Date
のオブジェクトは ISO 文字列になるため、リバイバーなしでは日付として復元されません。また、BigInt
は即座に例外をスローします。標準的なアプローチは、大きな整数を文字列としてシリアライズすることです。
末尾への追加を行う場合は、配列を書き換えるのではなく、改行区切りのJSONを記述してください:
import { appendFile } from "node:fs/promises";
await appendFile("./log.jsonl", JSON.stringify(record) + "\n", "utf8");
JSON配列の末尾に要素を追加するには、その配列を読み込み、解析し、要素を追加し、ファイル全体を書き換える必要があります。これは処理負荷が高く、書き込み途中でプロセスが異常終了した場合、ファイルが破損する原因となります。
よく聞かれる質問
Node.js で JSON ファイルを読み込むにはどうすればよいですか?
const data = JSON.parse(await readFile("./data.json", "utf8")) のように、node:fs/promises を使用します。あるいは、標準のインポート構文 import data from "./data.json" with { type: "json" } を使用することもできます。これは 2025 年時点で Baseline に準拠しており、Node およびブラウザの両方で動作します。
JavaScriptはブラウザ内でローカルファイルを読み込めますか?
パス指定では読み込めません。ブラウザは任意のローカルファイルを開くことはできません。これは意図的なセキュリティ上の制限です。ユーザーは<input type="file">やドラッグ&ドロップを通じてファイルを選択する必要があり、その後File.text()でその内容を取得できます。
import ... with { type: "json" } はどのような役割を果たしますか?
JSONファイルをモジュールとしてインポートすると同時に、サーバーがapplication/jsonというMIMEタイプでそのファイルを提供しているかどうかを検証します。このチェックがなければ、text/javascriptとして提供されたファイルはコードとして解析・実行されてしまいます。この属性は、まさにそのようなセキュリティ上の問題を防止するために存在します。
JSONを読み込む際に「Unexpected token '<'」というエラーが出るのはなぜですか?
コンテンツが < で始まっているためです。これは、JSONではなくHTML(通常はエラーページやログインリダイレクト)を受信したことを意味します。fetch を使用している場合、原因はほぼ間違いなく res.ok のチェックが欠落していることです。fetch はHTTPエラーステータスでは拒否しないためです。
JSONにはrequireとimportのどちらを使うべきですか?
新しいコードではimport ... with { type: "json" }を使用してください。これが標準であり、ESモジュールでも動作するからです。requireはCommonJS専用であり、結果をキャッシュするため、実行時に編集されたファイルは再読み込みされません。プログラムの実行中にファイルが変更される場合には、どちらも適していません。そのような場合はreadFileを使用してください。
非常に大きな JSON ファイルを読み込むにはどうすればよいですか?
ワーカーを使用してメインスレッドから解析処理を分離するか、データを改行区切りの JSON に再構成して、各行が一定のメモリ使用量で個別に解析されるようにします。変更されない大きな配列の場合は、ストリーミングパーサーを使用してください。また、そもそもリクエストするデータ量を減らせるかどうかを検討してください。
JSON.parse と response.json() の違いは何ですか?
Response.json() はフェッチのボディを読み込み、1 ステップでパースします。リバイバー関数は受け付けません。JSON.parse は、すでに取得済みの文字列を処理し、リバイバー関数を受け付けます。日付や大きな整数など、リバイバーが必要な場合は、res.text() を実行した後、JSON.parse を実行してください。
存在しない可能性のあるJSONファイルをどのように処理すればよいですか?
読み取りエラーと解析エラーを別々に捕捉してください。Node.jsでは、ENOENTというエラーコードはファイルが存在しないことを意味し、通常は失敗ではなくデフォルト値を指定する必要があります。一方、SyntaxErrorはファイルは存在するがその内容が間違っていることを意味します。
まとめ
その方法は環境によって異なりますが、最近の対応は以前よりも統一されてきています。import data from "./data.json" with { type: "json" }はNodeおよびブラウザで動作し、2025年時点で「Baseline」に指定されており、単なる形式的なものではなく、真のセキュリティ上の理由からMIMEタイプのチェックが行われます。
プログラムの実行中に変更されるファイルについては、明示的に読み込むようにしてください。Node.js では readFile に "utf8" エンコーディングを指定し、ブラウザでは fetch に res.ok チェックを組み込み、ユーザーが選択したファイルについては File.text() を使用します。この res.ok チェックは、この記事の中で最も価値のある一行です。なぜなら、これを省略することが、最も一般的な JSON エラーの直接的な原因となるからです。
何かが失敗した場合は、コードに手を加える前に、入力の最初の200文字をログに記録してください。Unexpected token '<'はHTMLを意味し、Unexpected end of JSON inputは空または切り捨てられたことを意味しますが、どちらもパーサーの挙動を推測するのではなく、実際に到着した内容を調べることで判断できます。
また、ファイルが大きくなりすぎている場合は、より高性能なマシンを導入するよりも、改行区切りのJSONを採用することが構造的な解決策となります。1行に1ドキュメントという形式であれば、メモリ使用量は一定に保たれ、安全に追加が可能であり、書き込みが中断されても、完全なレコードはすべて無傷のまま維持されます。
