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

要点:まず 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 サービス)はすべてこれに当たります。
対処法(おすすめ度順):
- ID はパイプライン全体で文字列にする:
{"id": "9007199254740993"}。これが最も手間が少ない。 - サーバー側で高精度型を使ってパースする:Java の
BigInteger/BigDecimal、Go のjson.Number、Python のdecimal.Decimal。 - フロントエンドでは
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 中国語が文字化けする三つの原因
- ファイル自体が GBK で、UTF-8 として読まれている。
- レスポンスヘッダが
charset=gbkを宣言しているが、中身は実際には UTF-8。 - 「文字列にデコードしてから再エンコードする」ミドルウェアを通り、中国語が二度変換される。
調査の順番:まずバイト列(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 として送られたのか」の曖昧さを避けてください。