JSON Toolkit

6 つの型、数値精度、エンコーディングの落とし穴

要点:JSON の型は 6 つだけですが、そのどれもがデータを静かに作り変えてしまいます。本記事では数値・文字列・null の落とし穴を一度に扱います。

初級約 12 分で読めます2026-10-30 公開2026-10-30 更新
6 つの型、数値精度、エンコーディングの落とし穴のカバー画像

要点:まず ID や金額のフィールドが 2^53 - 1 を超えるか、多くの小数桁精度を必要とするかを確認し、次に文字列長の制限がコードポイント基準か UTF-16 単位基準かを確認しましょう。安全な整数の範囲を超える値は、パーサーが精度を保つと信じるのではなく、文字列として扱います。

JSON の型は多くの人が思うより少なく、落とし穴はより多い。数値はずれ、文字列は壊れ、null は欠落キーとは別物です。

1. 型の全体像

型 リテラルの例 JSON での制約 よくある対応
Object {"a": 1} キーは文字列、値は任意 Object / Map / struct
Array [1, "a", null] 要素は異種でもよい Array / List / slice
String "text" UTF-8、二重引用符 String
Number 1、-1.5、1e9 先頭ゼロなし、+ なし、NaN なし double / Decimal
Boolean true / false 小文字 bool
null null 唯一の null 値 null / Option::None

存在しない二つに注意してください。日付型はなく、整数型もありません。日付はすべて文字列で、整数も浮動小数点も単一の「数値」型を共有します。

2. 数値:倍精度が既定の答え

2.1 安全な整数の境界

IEEE 754 倍精度が正確に表現できる最大の整数は 2^53 - 1 = 9007199254740991 です。これを超えると、隣り合う整数の間に隙間が生まれます。

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992

JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'

これは JSON の問題ではなく、「テキスト → 倍精度」のステップの問題です。途中に JavaScript が入るパイプライン(ゲートウェイ、BFF、フロントエンド、Node サービス)はすべてこれに当たります。

対処法(おすすめ度順):

  1. ID はパイプライン全体で文字列にする:{"id": "9007199254740993"}。これが最も手間が少ない。
  2. サーバー側で高精度型を使ってパースする:Java の BigInteger / BigDecimal、Go の json.Number、Python の decimal.Decimal。
  3. フロントエンドでは JSON.parse の reviver や、大きな数値を扱えるパーサー(json-bigint など)でフィールドを BigInt にする。ただし BigInt は JSON.stringify に直接渡せない点に注意。

2.2 小数は正確な値ではない

0.1 + 0.2            // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"

金額は常に最小通貨単位(セント)の整数か文字列で表現し、浮動小数点で加算してはなりません。

2.3 シリアライズ時の数値の落とし穴

JSON.stringify({ a: 1e21 })        // {"a":1e+21}
JSON.stringify({ a: -0 })          // {"a":0}
JSON.stringify({ a: Infinity })    // {"a":null}
JSON.stringify({ a: NaN })         // {"a":null}
JSON.stringify({ a: 10n })         // TypeError: Do not know how to serialize a BigInt

Infinity と NaN は無言で null に変わります。「データがいつの間にか消えた」系の原因追跡が最も難しい部類です。

2.4 言語ごとの既定の振る舞い

言語 既定の数値型 明示的な対応が必要なケース
JavaScript double 大きな整数、BigInt のシリアライズ
Go float64(encoding/json) 精度を保つには json.Number か Decoder.UseNumber()
Java(Jackson) リテラルから int/long/double/BigDecimal を推論 DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS を強制
Python int(任意精度)/ float 小数精度を保つには parse_float=Decimal
Rust(serde_json) i64/u64/f64 任意精度には arbitrary_precision を有効化

3. 文字列:UTF-16 を通して見る UTF-8 バイト列

3.1 長さは文字数と同じではない

JavaScript では "🎉".length は 2 です。絵文字は補助面にあり、サロゲートペアで表現されるからです。 サーバーが「文字数」で長さを制限し、フロントエンドが length で検証すると、両者の不一致は確実です。

'🎉'.length                 // 2
Array.from('🎉').length     // 1
[...'👨‍👩‍👧'].length           // 5(ゼロ幅接合子を含む)

厳密に数える必要があるときは、Intl.Segmenter(書記素クラスタ単位)を使うか、バックエンドで Unicode コードポイントを数え、API ドキュメントに定義を明記してください。

3.2 孤立サロゲート

"\ud800" のような孤立した上位サロゲートは文法的には正しい JSON です(\uXXXX は任意の 4 桁の 16 進数を受け付けます)が、正しい Unicode テキストではありません。 ES2019 以降、JSON.stringify は孤立サロゲートを \ud800 としてエスケープし、出力が「整形済み(well-formed)」であることを保証します。 言語をまたいで転送すると、相手側が Unicode を厳密に検証する場合、このような値は拒否されます。

3.3 \u2028 と \u2029

行区切りと段落区切りは JSON では正しいですが、JavaScript のソースコードや <script> タグに直接埋め込むと構文エラーになります。 JSON を HTML にインライン化するときは、必ずエスケープするか、文字列から JSON.parse でパースする方式に切り替えてください。

3.4 中国語が文字化けする三つの原因

  1. ファイル自体が GBK で、UTF-8 として読まれている。
  2. レスポンスヘッダが charset=gbk を宣言しているが、中身は実際には UTF-8。
  3. 「文字列にデコードしてから再エンコードする」ミドルウェアを通り、中国語が二度変換される。

調査の順番:まずバイト列(xxd)、次にレスポンスヘッダ、最後にミドルウェアを見ます。

4. null と「このキーは存在しない」の違い

JSON ではこの二つは異なります。

{"nickname": null}
{}
  • 前者は「フィールドは存在し、値が空」。
  • 後者は「フィールドが存在しない」。

TypeScript ではこれが string | null と nickname?: string の違いになり、Java では「明示的な null」と「既定値」の違いになります。 取り決めがなければ、次のようなばらつきが生まれます。

// フロントエンド A の方法
if (data.nickname === undefined) { /* ニックネームなしとして扱う */ }
// フロントエンド B の方法
if (!data.nickname) { /* null と空文字をまとめて扱う */ }

推奨:API ドキュメントに「値がない場合は一律で null を使い、キーを省略しない」と書く。またはその逆でもよいが、どちらか一方だけを選ぶ。 API を更新するときは PATCH のセマンティクスを使い、「送られなかったのか、null として送られたのか」の曖昧さを避ける。

5. 配列とオブジェクト

5.1 配列は「同種」ではない

[1, "two", {"three": 3}, null]

文法的には正しいですが、型定義を書くのが非常に難しくなります。API では配列の要素をできるだけ同種に保ち、異種の場合は type という判別フィールドを持つオブジェクトの配列に切り替えてください。

5.2 オブジェクトのキーは順序を持たないが、多くの人が順序に依存している

仕様はオブジェクトを「名前と値のペアの順序を持たない集合」と述べています。実際には V8 は挿入順を保ちます(整数キーはソートされる例外を除く)。 キーの順序(たとえば「最初のキーを取る」)に基づくロジックは、実装の詳細に依存していることになります。

5.3 疎な配列と穴は存在しない

JavaScript の [1, , 3] は [1,null,3] にシリアライズされます。穴は null になるのです。JSON には「スロット」という概念がありません。

6. 実行可能なチェックリスト

  • [ ] すべての ID、金額、巨大な整数は API 契約上で明示的に文字列になっている。
  • [ ] API ドキュメントが null とフィールド省略の意味を明記している。
  • [ ] 文字列長の制限がコードポイント基準か UTF-16 単位基準かを明記している。
  • [ ] シリアライズ前に Infinity / NaN / undefined を除去するか、明示的に null へ変換している。
  • [ ] HTML にインライン化する JSON はエスケープしている。
  • [ ] フロントエンドとバックエンドの双方に「大きな整数 + 絵文字 + null」をカバーする契約テストがある。

FAQ

2^53 - 1 を超える整数はどう扱えばよいですか?

最も手間が少ないのは、ID などのフィールドをパイプライン全体で文字列として送る方法です。たとえば {"id": "9007199254740993"}。サーバー側では Java の BigInteger / BigDecimal、Go の json.Number、Python の decimal.Decimal で精度を保てます。フロントエンドでは json-bigint のようなパーサーでフィールドを BigInt にできますが、BigInt を JSON.stringify に直接渡せないことを忘れないでください。

Infinity と NaN はなぜシリアライズ後に null になるのですか?

JSON の数値型は NaN や Infinity のような非標準リテラルを受け付けないため、JSON.stringify はエラーを出さずに無言で null に変換します。これは「データがいつの間にか消えた」系の原因追跡が最も難しい部類です。シリアライズ前にこれらの値を明示的に除去するか、業務上の取り決めに従って null に変換し、ログを残してください。

null を送るべきか、フィールドごと省略すべきか?

どちらでも動きますが、API ドキュメントで一方を選んで明確に述べる必要があります。さもないとフロントエンドがそれぞれ別の前提を持ちます。例では {"nickname": null} と {} が TypeScript の string | null と nickname?: string に対応します。API を更新するときは PATCH のセマンティクスを使い、「送られなかったのか、null として送られたのか」の曖昧さを避けてください。