プロキシ会社がなぜ「JSON.parse」について記事を書いているのか、一点補足しておきます。私たちはGeonodeというプロキシ販売会社ですが、お客様から最も頻繁に報告されるSyntaxErrorは、JSONとは全く関係がありません。それは「Unexpected token '<'」というエラーで、これはレスポンスがJSONではなくHTMLであったことを意味し、APIがブロックページ、ログインリダイレクト、またはエラーページを返したことを示しています。 そこで、率直な注意点を述べておきます:取得したデータに対して「JSON.parse」というエラーが発生している場合は、他の設定を変更する前に、生のレスポンス本文をログに記録してください。 10回中9回は、パーサーは正常に動作しており、Webページが送信されたことを正確に報告しているのです。 プロキシを購入しても、ブロックされたという特定のケースでのみこの問題は解決されます。URLの誤り、トークンの有効期限切れ、あるいは遵守すべきレート制限については何の解決にもなりません。まずはレスポンスを出力してください。
その点はさておき、実際のAPIについて説明します。
基本と実際に遭遇するエラー
const data = JSON.parse('{"name": "Ada", "born": 1815}');
// { name: "Ada", born: 1815 }
パラメータは2つ:テキストと、オプションの復元関数です。これだけです。
入力がJSONの文法に違反している場合、SyntaxError
がスローされます。JSONの文法はJavaScriptのオブジェクトリテラル構文よりも厳格であり、その点がユーザーを惑わせることがあります。失敗の原因は、主に以下の4つのケースに集約されます。
一重引用符。 MDN では明確に「JSONの文字列は二重引用符(一重引用符ではない)で囲まなければならない」と規定されています。JavaScriptとしては有効ですが、JSONとしては無効です。
JSON.parse("{'name': 'Ada'}"); // SyntaxError
JSON.parse('{"name": "Ada"}'); // fine
末尾のコンマ。 現代のJavaScriptでは有効ですが、JSONでは無効です:
JSON.parse("[1, 2, 3, 4, ]"); // SyntaxError
引用符で囲まれていないキー。 {name: "Ada"}
これは有効なオブジェクトリテラルですが、JSONではありません。キーは引用符で囲まれた文字列でなければなりません。
レスポンスがJSONではありませんでした。 上記で説明したものです。Unexpected token '<'
は、ボディが <
で始まっていることを意味し、これはHTMLであることを示します。Unexpected end of JSON input
は通常、空のボディを意味します — 204、切り詰められたレスポンス、または await を忘れた fetch などです。
常にこれをラップし、実際に受け取った内容をログに出力してください:
function parseOrThrow(text, url) {
try {
return JSON.parse(text);
} catch (err) {
throw new Error(
`Failed to parse JSON from ${url}: ${err.message}. ` +
`First 200 chars: ${text.slice(0, 200)}`
);
}
}
重要なのは ``text.slice(0, 200)`
の部分です。単なる ``SyntaxError
` であれば、解析に失敗したことを示しています。最初の 200 文字にその理由が記載されており、通常はすぐに原因がわかります。
リバイバー関数とその用途
2番目の引数は、値が解析される際にその値を変換します:
const data = JSON.parse(text, (key, value) => {
if (key === "created") return new Date(value);
return value;
});
正確に知っておくべき3つの挙動があります。
深さ優先で実行されます。 ネストされたプロパティは親プロパティよりも先に処理され、最後の呼び出しではルート値のキーとして空の文字列が使用されます。 したがって、リバイバーがオブジェクトを処理する段階では、その子要素はすでに処理済みとなっています。
**undefined
を返すと、そのプロパティが削除されます。** MDN:「reviver
関数がundefined
を返す(または値を返さない)場合、そのプロパティはオブジェクトから削除されます。」 これはうっかり引き起こしやすい問題です。条件分岐の末尾に達したリバイバーは ``undefined`
を返し、キーを黙って削除してしまいます。デフォルトとして、常に明示的に ``value
` を返すようにしてください。
ルートは完全に置き換えられます。 「reviver
から別の値を返した場合、その値は最初に解析された値を完全に置き換えます。これはルート値にも適用されます。"
JSON には日付型がないため、典型的な用途は日付の復元です:
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const data = JSON.parse(text, (key, value) =>
typeof value === "string" && ISO_DATE.test(value)
? new Date(value)
: value
);
パターンマッチングには注意が必要です。日付のような形式のものをすべて変換するリバイバーは、文字列として保持したい文字列(バージョン番号、識別子、たまたまタイムスタンプのように見えるユーザーコンテンツなど)も変換してしまいます。スキーマが分かっている場合は、キー名でのマッチングを優先してください。
また、コストにも注意が必要です。リバイバーはドキュメント内の値ごとに1回呼び出されます。大規模なペイロードの場合、これはパフォーマンス上の重大な考慮事項となります。多くの場合、単純にパースし、その後必要なフィールドのみを変換する方がコスト効率が良いでしょう。
ソーステキストへのアクセス:context.source および JSON.rawJSON
これは最近追加された機能の中で最も重要なものであり、多くの開発者はまだこれに触れたことがないでしょう。
JSON.parse ソーステキストへのアクセスに関する提案は、TC39プロセスのステージ4に達しました。これは、標準として承認されたことを意味します。この提案は、「ECMAScriptの値とJSONテキスト間の変換には情報損失が生じる」という、提案書で直接指摘されている問題を解決するものです。
reviver は、プリミティブ値に対して 3 番目の引数を受け取るようになりました。MDN は ``context.source`
を「この値を表す元の JSON 文字列」と説明していますが、提案書ではさらに正確に、これを「句読点を含み、先頭および末尾の無意味な空白を除いたソーステキスト」と定義しており、これに加え ``index
、``input
、``keys
` についても言及しています。
これがなぜ重要なのかは、次の例を見れば明らかです:
const text = '{"id": 9007199254740993}';
JSON.parse(text).id;
// 9007199254740992 — wrong, silently
JSON.parse(text, (key, value, context) =>
key === "id" ? BigInt(context.source) : value
).id;
// 9007199254740993n — correct
ソースへのアクセスがない場合、reviverが数値を認識する時点で、その数値はすでにJavaScriptのdouble型に変換されています。介入する間もなく、精度は失われてしまうのです。context.source
を使用すれば、元の数字を取得できます。
この提案では、JSON.rawJSON()
も追加されています。これにより、生のJSONテキストを指定でき、JSON.stringify
がそれを変更せずに出力できるようになります。これにより、JSONから読み込んだBigIntを、データが破損することなく書き戻すことが可能になり、ラウンドトリップが完結します。
JSON.stringify({ id: JSON.rawJSON("9007199254740993") });
// '{"id":9007199254740993}'
これを利用する前に、対象環境でのサポートを確認してください。しかし、これはもはや回避策ではなく、大数問題に対する正しい解決策となっています。
数値の精度こそが、リリース時に残ってしまうバグである
この問題は、黙って失敗し、症状が根本原因とはかけ離れた場所に現れるため、独立したセクションを設ける価値がある。
JSONの数値はJavaScriptの数値に変換されますが、これはIEEE 754準拠のdouble型です。Number.MAX_SAFE_INTEGER(9,007,199,254,740,991)を超える整数は、すべて正確に表現できるわけではありません。MDNはこれを率直に「この処理の過程で精度が失われる可能性がある」と述べています。
危険な点は、例外が投げられないことです。数値は返されますが、それは単に送信された数値そのものではないのです。
JSON.parse('{"id": 12345678901234567890}').id;
// 12345678901234567000
実務上で問題となる場面:
- データベースの識別子。 64ビット整数の主キーは安全な範囲を超えています。2つの異なるレコードが、同じJavaScript数値として解析されてしまう可能性があります。
- Snowflake スタイルの ID。 いくつかの大規模プラットフォームで使用されており、日常的に制限値を超えています。
- 小単位の金額。 セントやサトシ単位の多額の金額。
- ナノ秒単位のタイムスタンプ。 1970年以降のナノ秒単位のエポック値は、すでに安全な範囲外となっています。
優先順位の高い順に3つの対策:
文字列として要求する。 APIを管理できる場合は、大きな識別子を文字列としてシリアライズしてください。これは最もクリーンな解決策であり、どこでも機能し、コストもかかりません。 MDNもまさにこれを推奨しています:「精度を損なうことなく大きな数値を転送する1つの方法は、それらを文字列としてシリアライズし、BigIntとして復元することです。」
context.sourceをリバイバーと併用する。 上記と同様、プロデューサーを制御できず、かつ環境がこれをサポートしている場合。
BigInt に対応した JSON ライブラリを使用する。 古い環境の場合、これを処理できるパーサーがいくつかあります。依存関係が増え、パフォーマンスが多少低下します。
効果がない方法:パース後に数値が「正しく見えるか」を確認すること。その時点では情報は失われており、誤った値と正しい値を区別することはできません。
__proto__ とプロトタイプ汚染
MDN は、JSON と JavaScript の意味が異なる唯一のケースを次のように指摘しています。「JSON テキストが、同じ JavaScript 式とは異なる値を表す唯一のケースは、\"__proto__\"キーを扱う場合です。」
JavaScriptのオブジェクトリテラルでは、__proto__はプロトタイプを設定します。一方、JSON.parseでは、通常の自身のプロパティを作成します:
const fromLiteral = { __proto__: { admin: true } };
fromLiteral.admin; // true — prototype was set
const fromJson = JSON.parse('{"__proto__": {"admin": true}}');
fromJson.admin; // undefined — plain own property
Object.hasOwn(fromJson, "__proto__"); // true
したがって、JSON.parse自体はここでは安全です。これは意図された正しい動作です。
危険なのは、その後に何が起こるかです。プロトタイプ汚染の脆弱性は、ほとんどの場合、危険なキーをフィルタリングしないコードによって、解析されたデータが別のオブジェクトにマージされる際に発生します:
// Unsafe: a naive deep merge can walk into Object.prototype
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
merge(target[key] ?? (target[key] = {}), source[key]);
} else {
target[key] = source[key];
}
}
}
__proto__ を含むペイロードを投入すれば、プログラム全体において Object.prototype を改変できてしまいます。防御策:
危険なキーを明示的にフィルタリングする — __proto__, constructor, prototype — 信頼できないデータに接触するあらゆるマージや代入において。
Object.create(null) を使用する — 信頼できないキーを保持するオブジェクトに対しては、汚染されるプロトタイプが存在しないようにするため。
Map を使用する。構造化されたオブジェクトではなく、純粋にキーバリューストアを構築する場合にこれを使用します。
スキーマに基づいて検証を行う。これは一般的な解決策であり、他の問題も検出できます。解析と検証は別々のステップであり、どちらも必要です。
JSON.parse 対 eval 対 Response.json()
eval は絶対に使用しないでください。 これは任意のコードを実行してしまう上、この用途では処理速度が遅く、JSON ではないデータも受け入れてしまいます。JSON を解析する際に eval が適切なツールとなるケースは一切ありません。
** Response.json()** は、fetch を使用する際に適しています。これはボディを読み込み、1 ステップで解析を行います:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
res.ok のチェックは、多くの人が省略しがちな部分ですが、これを省略することが、冒頭で述べた Unexpected token '<' エラーの直接的な原因となります。fetch は HTTP エラーステータスを理由に処理を中断しません。つまり、403 や 500 であっても通常通り処理が進み、その後 .json() がエラーページを解析しようとしてしまいます。まずステータスを確認し、徹底したい場合は content-type も確認してください:
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)}`);
}
const data = await res.json();
なお、Response.json() はリバイバーを受け付けません。リバイバーが必要な場合は、res.text() の後に JSON.parse を実行してください。
この点についてもライブラリによって異なります。2xx以外のステータスコードに対して自動的に解析を行い、例外をスローするライブラリもあり、その場合はエラー処理の場所が変わってきます。axios vs fetch でその挙動を比較しました。
ページをフリーズさせずに大規模なJSONを解析する
JSON.parseは同期処理であり、ブロックされます。メインスレッドで大規模なドキュメントを解析すると、その処理中はインターフェースがフリーズしてしまいます。これは、動作のカクつき(jank)の一般的な原因であり、診断も容易です。
大まかな目安:1メガバイト未満であれば、特に気にする必要はありません。 1~10メガバイトの場合は、最も処理が遅いターゲット端末で測定してください。10メガバイトを超える場合は、別の方法を検討してください。
手間のかかる順に、選択肢を挙げます:
Web Workerに移行する。 最も単純で確実な解決策です。メインスレッド外で解析を行い、結果を戻します。 結果の転送には構造化クローン処理のコストがかかるため、ワーカーが後続の処理も行う場合に最も効果的です。
取得するデータ量を減らす。 ページネーション、フィールドの選択、より限定されたエンドポイントなど。ほとんどの場合、これが正しい解決策ですが、APIの管理者と協議が必要になるため、ほとんどの場合見過ごされてしまいます。
ストリーミングパーサーを使用する。 ツリー全体を構築するのではなく、値が到着するたびにそれを出力するライブラリが存在します。ドキュメントが本当に大きい場合や、ペイロードの一部のみが必要な場合には、この手法が有効です。
改行区切りのJSONを使用する。 大規模なコレクションの場合、1行に1つのJSONドキュメントとする方が、段階的な処理が格段に容易になります。各行が独立して解析され、ストリームが途中で途切れても完全なレコードが得られるからです。フォーマットを制御できる場合は、これがより優れた設計となることが多く、他のフォーマットとのトレードオフについては、JSONとCSVの比較で解説しています。
「JSON.parse」が不適切なツールとなる場合
入力がJSONでない場合。 JSON5、JSONC、およびコメントや末尾のコンマを含む設定ファイルには、それぞれ専用のパーサーが必要です。JSON.parseはこれらを正しく拒否します。正規表現を使ってコメントを削除しようとすべきではありません。そうすると、本来書くつもりもなかったようなパーサーを作ることになってしまいます。
単なる解析だけでなく、バリデーションが必要な場合。 解析が成功したからといって、構文が有効だったということしかわかりません。必須フィールドが存在するか、正しい型であるかについては何も示していません。まず解析を行い、その後バリデーションを行ってください。2番目のステップにはスキーマ検証ライブラリが適切なツールであり、try/catchは適していません。
データが損失なく往復処理されなければならない場合。 大きな整数、日付、undefined、関数、Map、Set、NaN、Infinity — これらはいずれもJSONではそのままの形で保持されません。損失のない往復処理が要件である場合は、context.source や JSON.rawJSON を意図的に使用するか、その目的のために設計されたフォーマットを使用してください。
レンダリングのたびにパースを行っている場合。 ホットパスで同じ文字列を繰り返しパースするのは、まったくの無駄です。一度パースしてキャッシュしましょう。
文字列が、検証していないフェッチから取得された場合。 冒頭で挙げたケースですが、これが最も一般的であるため改めて指摘します。フェッチしたデータに対して JSON.parse が例外をスローする場合は、バグはアップストリームにあります。ステータスコードを確認し、コンテンツタイプを確認し、ボディをログに記録してください。パーサーは真実を伝えています。
よくある質問
JSON.parse はどのような役割を果たしますか?
JSON形式の文字列を、JavaScriptの値(オブジェクト、配列、文字列、数値、ブール値、またはnull)に変換します。オプションで、解析時に各値を変換できるリバイバー関数を指定できます。入力が有効なJSONでない場合、SyntaxError 例外をスローします。
JSON.parse で「Unexpected token '<'」というエラーが出る理由は?
これは、文字列が < で始まっているためです。これは、JSON ではなく HTML を受け取ったことを意味します。通常、エラーページ、ログインリダイレクト、またはブロックページなどが該当します。パーサーの動作は正しく、問題はリクエスト側にあります。レスポンスボディの最初の 200 文字をログに出力すれば、通常は原因が明らかになります。
JavaScriptで大きな数値を含むJSONをパースするにはどうすればよいですか?
reviverのcontext.source引数を使用して、元の桁を読み取り、BigIntを構築してください。値がreviverに到達する頃には、すでに精度が失われているためです。さらに良い方法は、APIを制御できる場合は、大きな識別子を文字列としてシリアライズすることです。
JSON.parse の reviver 関数とは何ですか?
各キー・値ペアに対して、深さ優先で呼び出されるオプションの 2 番目の引数です。処理は空文字列のキーを持つルートで終了します。この関数が返す値が、そのキーの値として置き換えられます。undefinedを返すと、そのプロパティが削除されます。通常、この関数の役割は、文字列を Date などのより詳細な型に変換することです。
JSON.parseは安全ですか?
コードの実行という点では、はい。evalとは異なり、何も実行することはありません。また、__proto__も安全に処理し、プロトタイプを設定するのではなく、単純な独自のプロパティを作成します。リスクは、その後の処理にあります。信頼できない解析済みデータをフィルタリングせずに他のオブジェクトにマージすること(__proto__やconstructor)が、プロトタイプ汚染の原因となります。
JSON.parse と Response.json() の違いは何ですか?
Response.json() は、Fetch レスポンスのボディを読み取り、1 ステップでパースします。また、リバイバーを受け付けません。JSON.parse は、すでに持っている文字列に対して動作します。fetch は HTTP エラーを理由に処理を中断しない点に注意してください。そのため、.json() を呼び出す前に res.ok を確認してください。そうしないと、エラーページをパースしてしまうことになります。
JSON.parse はコメントや末尾のコンマを処理できますか?
いいえ。どちらも無効な JSON であり、SyntaxError が発生します。入力にこれらが含まれている場合は、JSON5 または JSONC であるため、それらを削除する正規表現ではなく、その形式に対応したパーサーが必要です。
JSON.parse はメインスレッドをブロックしますか?
はい、同期処理です。1メガバイト未満のドキュメントであれば問題ありませんが、大規模なペイロードの場合、目に見えるほどのフリーズを引き起こします。パース処理を Web Worker に移行するか、取得するデータ量を減らすか、ストリーミングパーサーを使用してください。
まとめ `
`JSON.parse`` は 2 つのパラメータを持つシグネチャを持ち、その背後には驚くほど深い意味が隠されています。特に留意すべき点は、エラーが派手には現れず、静かに失敗してしまう部分です。
数値の精度に関する問題が最も深刻です。大きな整数が静かに破損し、例外も発生せず、下流の処理で何かが壊れるまで、誤った値と正しい値を見分けることができません。解決策としては、ソース側で識別子を文字列エンコードするか、reviverのcontext.source引数を使用することです。この機能は現在ステージ4にあり、標準の一部となっています。
reviverは、特に日付の処理において、現在よりももっと活用されるべきです。ただし、デフォルトのパスでvalueを返すのを忘れると、プロパティが静かに削除されてしまう点には注意が必要です。また、プロトタイプの汚染はJSON.parseの問題では全くありません(この関数は__proto__を正しく処理します)が、結果をマージする側にとっては問題であり、影響が及ぶほど近い位置にあるため、重要な問題となります。
それ以外の問題はすべて、ある一つの習慣に帰着します。取得したデータの解析に失敗した場合は、コードを変更する前に生のボディをログに出してください。エラーメッセージにはほぼ必ず答えが含まれており、たいていの場合、そもそもJSONを受け取っていなかったという事実が判明します。