背景
自分のサイトに、JSON を整形・検証するツールを置いています。
JSON は、{"キー": 値} の形でデータを書くテキスト形式だ。
ツールは入力を JSON.parse で読み、JSON.stringify でインデントを付けて書き戻しています。
あるとき、整形結果を元のファイルと見比べたら、キーの並びが変わっていました。
整形しただけなので中身は同じはず、と思っていたので少し焦りました…
調べたら、ツールのバグではなく JavaScript の決まりどおりの動きでした。
結論
JSON.parse で作ったオブジェクトは、「配列のインデックスとして読めるキー」を数値の小さい順で先頭に並べ、残りのキーを元の順に並べます。
なので、"1" や "10" のようなキーがあると、整形しただけで順番が変わる。
同じキーが2回出てくると、後の値だけが残ります。
JSON の仕様(RFC 8259)は、オブジェクトを「順序の無い名前と値の組の集まり」と定めていて、キーの重複も「すべきでない(SHOULD be unique)」としか書いていません。
順番に意味を持たせたいデータは、オブジェクトではなく配列で書くのが安全です。
数字のキーが前に出る
Node.js v24.11.1 で、下記を実行しました。
s1 は、文字のキーと数字のキーを交互に並べた JSON 文字列です。
const s1 = '{"b":1,"2":2,"a":3,"1":4}';
console.log(JSON.stringify(JSON.parse(s1)));
出力は下記でした。
{"1":4,"2":2,"b":1,"a":3}
"1" と "2" が先頭に移り、"b" と "a" は元の順(b が先)のまま後ろに並びました。
文字のキー同士はアルファベット順に並べ替えられるわけではない、というのがポイントです。
これは ECMAScript(JavaScript の仕様)が、オブジェクトのキーを列挙する順番を決めているためだ。
「配列のインデックス」として読めるキーは数値の昇順で先に、それ以外の文字列キーは追加した順で後に並べる、という決まりになっています。
どこまでが「数字のキー」か
境目を確かめるために、いろいろな形のキーを混ぜてみました。
const s2 = '{"id":"x","10":"ten","9":"nine","-1":"m","01":"z",' +
'"4294967294":"big","4294967295":"big2","1.5":"f"}';
console.log(JSON.stringify(JSON.parse(s2)));
出力は下記です。
{"9":"nine","10":"ten","4294967294":"big","id":"x","-1":"m","01":"z","4294967295":"big2","1.5":"f"}
先頭に移ったのは "9"、"10"、"4294967294" の3つでした。
並びも文字列順ではなく数値順なので、"9" が "10" より前に来ています。
"-1"(負の数)、"01"(先頭に0が付く)、"1.5"(小数)は、元の順のまま後ろに残りました。
"4294967295" も後ろ側です。
配列のインデックスは 0 から 2の32乗 − 2(4294967294)までの整数と決まっているので、それより1大きい値は普通の文字列キー扱いになる、ということらしいです。
重複したキーは後の値が残る
キーが重複した JSON も試しました。
const s3 = '{"name":"a","age":20,"name":"b"}';
console.log(JSON.stringify(JSON.parse(s3)));
出力は {"name":"b","age":20} でした。
値は後から出てきた "b" に上書きされ、位置は最初に name が出てきた先頭のままです。
エラーにも警告にもならないので、整形結果だけ見ていると "a" があったことに気づけません。
Python では順番が保たれる
比較のために、Python 3.11.9 の json モジュールでも同じ文字列を読んで書き戻しました。
import json
print(json.dumps(json.loads('{"b":1,"2":2,"a":3,"1":4}')))
print(json.loads('{"name":"a","age":20,"name":"b"}', object_pairs_hook=lambda p: p))
出力は下記でした。
{"b": 1, "2": 2, "a": 3, "1": 4}
[('name', 'a'), ('age', 20), ('name', 'b')]
Python の辞書は追加した順を保つので、キーの順番は元のままでした。
object_pairs_hook は、オブジェクトを辞書にする前の「キーと値の組のリスト」を受け取れる引数です。
ここで組をそのまま返すと、重複した name が2つとも見えます。
同じ JSON でも、読む言語によって整形後の並びが変わる。
差分を取ったり、人が目で確認したりする場面では、これが地味に効いてきます。
ツールではどうしたか
ツールは今も JSON.parse と JSON.stringify で整形しています。
自前でパーサを書けば元の順を保てますが、そのぶん検証の結果がブラウザ標準とずれる危険が増える。
なので「キーの順番は保証しない」という前提を、ここに書いておくことにしました。
作ったツールはここに置いています。
https://hashitosystem.com/tools/jsonformat/
キーの順番そのものに意味があるデータなら、[{"key":"b","value":1}, ...] のように配列で持つ。
重複キーが心配な JSON は、Python の object_pairs_hook のような、組をそのまま受け取れる読み方で一度確かめる。
JavaScript の JSON.parse は、順番も重複も教えてくれないものだと割り切って使っています。
本記事はAI補助で執筆した、個人開発の紹介記事です。