- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
HTMLもJSONも、ハンドラが返すのは文字列です。返す相手がブラウザでなくなっても、アプリがやっていることは変わりません。
まず結論
ハンドラが返すものは、相手がブラウザでも他のプログラムでも文字列です。HTMLとJSONの違いは返し先ではなく表現の違いで、どちらとして読むかを Content-Type が告げます。
ただしJSONは型が薄く、数値には精度の規定がありません。RFC 8259 §6 に上限の規定は無く、あるのは制限を許す一文と、「IEEE 754 binary64 を使う実装同士なら、整数は [-(2**53)+1, (2**53)-1] の範囲で正確に一致する」という相互運用の注記です。この範囲の外では、エラーにならずに値が変わります。
JavaScript で `JSON.parse('{"id": 9007199254740993}')` を実行すると、`id` の値はどうなるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
返す相手が変わっても、返すものは文字列のまま
ここまで、ハンドラは文字列を組み立てて接続に書き出しているだけだと見てきました。相手がブラウザから別のプログラムに変わっても、この部分は変わりません。変わるのはその文字列をどの表現として読むかだけです。第7期の『なぜ Content-Type と文字コードが要るのか』で見たとおり、本文には「これは HTML だ」とも「これは JSON だ」とも書かれていません。告げるのはヘッダです。
返すもの 本文 Content-TypeHTML <h1>kazu</h1> text/html; charset=utf-8JSON {"name":"kazu"} application/json
どちらも文字列で、同じ接続の上を流れます。「JSON は別の経路を使う」ということはありません。
JSON 側に charset が無いのは書き忘れではありません。§11 の登録の注記です。
Note: No "charset" parameter is defined for this registration. Adding one really has no effect on compliant recipients.
charset は登録上定義されていないパラメータで、準拠する受信側には効果がありません(有害だとは書かれていません)。文字コード自体は §8.1 が別に規定し、その MUST は「closed ecosystem の一部ではないシステム間」に限定されます。
JSON text exchanged between systems that are not part of a closed ecosystem MUST be encoded using UTF-8 [RFC3629].
型が薄いところで、値が静かに変わる
ただし JSON は型が薄く、文字列・数値・真偽値・null・配列・オブジェクトしかありません。「64bit の整数」という型はありません。DB の id が 64bit 整数だとして、JSON の数値のまま返すとどうなるか。実機で確かめました。
$ node -e 'console.log(JSON.stringify({id: 9007199254740993})); console.log(JSON.parse("9007199254740993")); console.log(Number.MAX_SAFE_INTEGER); console.log(9007199254740993 === 9007199254740992); console.log(BigInt("9007199254740993").toString())'{"id":9007199254740992}90071992547409929007199254740991true9007199254740993
1行目に注目してください。渡したのは …993 なのに、出てきたのは …992 です。大事なのは、JSON.stringify が丸めたのではないことです。リテラルが評価された時点で、すでに …992 になっています。4行目の true がその証拠で、異なる2つの整数リテラルが等しいと判定されています。
3行目が Number.MAX_SAFE_INTEGER です。ECMA-262(2026-09-16 時点のドラフト §21.1.2.6)の定義はこうです。
The value of Number.MAX_SAFE_INTEGER is 9007199254740991𝔽 (𝔽(2^53 − 1)).
同じ節の Note が今の現象を述べます。
Due to rounding behaviour necessitated by precision limitations of IEEE 754-2019, the Number value for every integer greater than Number.MAX_SAFE_INTEGER is shared with at least one other integer. Such large-magnitude integers are therefore not safe, and are not guaranteed to be exactly representable as Number values or even to be distinguishable from each other. For example, both 9007199254740992 and 9007199254740993 evaluate to the Number value 9007199254740992𝔽.
仕様自身が 9007199254740993 を例に挙げ、9007199254740992 になると書いています。実機と一致します。
5行目は同じ文字列を BigInt に渡した場合で、…993 が保たれます。失われるのは値そのものではなく Number という型を通ったせいだ、と切り分けられます。
Node で `JSON.stringify({id: 9007199254740993})` が `{"id":9007199254740992}` を出力しました。丸めが起きたのはどの時点ですか。
RFC は上限を定めていない — 定めていないことが要点
「JSON の整数は 2^53 まで」という説明を見ます。結果は近いのですが、帰属が誤りです。RFC 8259 §6 が書くのは上限ではありません。
This specification allows implementations to set limits on the range and precision of numbers accepted.
規格は精度を規定していません。制限を設けてよいと書くだけです。次の注記が続きます。
Note that when such software is used, numbers that are integers and are in the range [-(253)+1, (253)-1] are interoperable in the sense that implementations will agree exactly on their numeric values.
such software は直前の段落の「IEEE 754 binary64 を実装するソフトウェア」です。つまり上限ではなく、binary64 を使う実装同士なら、この範囲の整数について値が正確に一致するという相互運用の条件です。
検算します。2^53 − 1 = 9,007,199,254,740,991。実機3行目と一致します。
同じ JSON を、別の数値型で読む
実装の数値型の問題だと分かれば、別の実装なら結果が違うはずだと予測できます。同じ文字列を Python に読ませました。
$ python3 -c 'import json; print(json.loads("9007199254740993"))'9007199254740993$ python3 -c 'import json; print(json.dumps({"id": 9007199254740993})); print(type(json.loads("9007199254740993")))'{"id": 9007199254740993}<class 'int'>
…993 のままです。Python の int は任意精度で binary64 に縛られません。どちらも壊れていません。 RFC が精度を規定していない以上、両方とも準拠しています。「JSON で送れば同じ値が届く」という前提が、範囲の外では成り立たないだけです。
これは第2週『2進数 — オンとオフの並びで、なぜ数が表せるのか』で見た桁あふれの再演です。あのときの教訓は「エラーにならず、静かに間違った値が出る」でした。今回も例外は出ません。違うのは、原因がビット数の上限か浮動小数点形式の精度かだけです。
同じ `9007199254740993` という JSON を、Python の `json.loads` は …993 のまま読みました。この差の理由として正確なものはどれですか。
id を JSON の数値として返す
型の意味は残り、受け取った側は変換なしに数値として扱えます。ただし値は必ず相手の数値型を通り、binary64 の実装ではその精度に従います。範囲を超えても例外は出ません。
読んだ実装 受け取った id 桁Node v24.21.0 9007199254740992 変わったPython 3.14.6 9007199254740993 保たれた
id を JSON の文字列として返す
数値型を通らないので、どの実装で読んでも文字の並びが残ります。代わりに受け取った側は数値としての意味を持たず、比較や算術に使うには自分で変換の判断をします。
読んだ実装 受け取った id 型Node v24.21.0 "9007199254740993" 文字列Python 3.14.6 "9007199254740993" 文字列
同じ id を、数値で返す場合と文字列で返す場合。文字列にすると何を失い、何を守ることになるでしょうか。
「型」という言葉を、受け取った側が何をできるか・何を保証されるか、の2つに分けて考えてみてください。
図で見る — ハンドラから相手まで
- ・分かれるのは3段目から。2段目までは同じ
- ・値が変わりうるのは4段目。JSON の文法上は正しいまま通過する
図の4段目が要点です。JSON としては 9007199254740993 が正しく書かれ、運ばれ、パースされます。違反はどこにもありません。それでも、受け取った側の型に写した瞬間に値が変わることがあります。
この章のまとめ
- ハンドラが返すのは、相手が誰でも文字列。HTML と JSON は表現の違いで、宣言は Content-Type。
application/jsonの登録(RFC 8259 §11)に charset パラメータは定義されていない。付けても準拠する受信側には効果が無い。文字コードは §8.1 が別に扱い、MUST は「closed ecosystem の一部ではないシステム間」に限定される。- RFC 8259 は数値の精度を規定していない。 §6 が示すのは「binary64 を使う実装同士なら
[-(2**53)+1, (2**53)-1]の範囲で値が一致する」という相互運用の注記。「RFC が上限を定めた」は誤帰属。 Number.MAX_SAFE_INTEGERは 9,007,199,254,740,991(2^53 − 1)。ECMA-262 自身が9007199254740993→9007199254740992を例に挙げている。- 丸まるのは
JSON.stringifyのせいではなく数値リテラルが評価された時点。 - 同じ JSON を Python の
json.loadsは…993のまま読む。どちらも規格違反ではない。数値型が違うだけ。 - 文字列で返せば桁は守れるが型の意味を失う。取引であって一般則ではない。
- この誤りはエラーにも状態コードにも現れない。観測に出ないので実行では見つからない。
APIの表現設計は既存クイズの API設計I(RESTの設計と成熟度) と API設計III(表現・ページング・エラー) で、内容交渉は http-rfc9110 の群で扱っています。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
「HTMLを返すのがWebページ、JSONを返すのがAPIで、アプリの中の仕組みが別物になる」という説明は妥当ですか。
用語集(3語)
- Number.MAX_SAFE_INTEGER
- ECMA-262 が定める値で 9007199254740991(2^53 − 1)。これを超える整数の Number 値は、少なくとも1つの別の整数と共有される(2026-09-16 時点のドラフト §21.1.2.6 の Note)。
- IEEE 754 binary64
- 倍精度の2進浮動小数点形式。JavaScript の Number はこの形式で、RFC 8259 §6 が相互運用の基準として名前を挙げる形式でもある。
- application/json
- JSON テキストのメディアタイプ(RFC 8259 §11)。必須・省略可能パラメータともに n/a で、charset はこの登録では定義されていない。