1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSON を整形しただけでキーの順番が変わり、重複したキーは1つ消えた。JSON.parse は数字のキーを先頭に並べ直す

1
Posted at

背景

自分のサイトに、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補助で執筆した、個人開発の紹介記事です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?