Sechs Typen, Zahlenpräzision und Kodierungsfallen
Fazit: JSON kennt nur sechs Typen, aber jeder davon kann Ihre Daten still umformen. Dieser Artikel behandelt die Fallen von Zahlen, Strings und null in einem Durchgang.

Kurz gesagt: Prüfen Sie zuerst, ob ID- oder Geldbetragsfelder 2^53 - 1 überschreiten oder viele Nachkommastellen Präzision benötigen, und dann, ob String-Limits Codepoints oder UTF-16-Einheiten zählen; führen Sie jeden Wert jenseits des sicheren Ganzzahlbereichs als String, statt dem Parser zu vertrauen, dass er die Präzision bewahrt.
JSON hat weniger Typen, als die meisten Menschen denken, und mehr Fallen: Zahlen verschieben sich, Strings brechen, und null ist etwas anderes als ein fehlender Schlüssel.
1. Die Typenlandschaft
| Typ | Literalbeispiel | Einschränkung in JSON | Übliche Abbildung |
|---|---|---|---|
| Objekt | {"a": 1} |
Schlüssel müssen Strings sein, Werte beliebig | Object / Map / struct |
| Array | [1, "a", null] |
Elemente dürfen heterogen sein | Array / List / slice |
| String | "text" |
UTF-8, doppelte Anführungszeichen | String |
| Zahl | 1, -1.5, 1e9 |
Keine führenden Nullen, kein +, kein NaN |
double / Decimal |
| Boolean | true / false |
Kleinschreibung | bool |
| null | null |
Der einzige Nullwert | null / Option::None |
Beachten Sie die beiden Dinge, die es nicht gibt: Es gibt keinen Datumstyp und keinen Ganzzahltyp. Alle Daten sind Strings, und alle Ganzzahlen und Gleitkommawerte teilen sich den einzigen Typ „Zahl“.
2. Zahlen: Doppelte Präzision ist die Standardantwort
2.1 Die Grenze der sicheren Ganzzahl
Die größte Ganzzahl, die IEEE 754 doppelte Präzision exakt darstellen kann, ist 2^53 - 1 = 9007199254740991. Darüber hinaus entstehen Lücken zwischen benachbarten Ganzzahlen:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
Das ist kein JSON-Problem, sondern eines des Schritts „Text → doppelte Präzision“. Jede Pipeline mit JavaScript in der Mitte (Gateway, BFF, Frontend, Node-Dienst) stößt darauf.
Abhilfen (nach Empfehlung sortiert):
- IDs über die gesamte Pipeline als Strings verwenden:
{"id": "9007199254740993"}, der geringste Aufwand von allen; - Auf dem Server mit Typen hoher Präzision parsen: Javas
BigInteger/BigDecimal, Gosjson.Number, Pythonsdecimal.Decimal; - Im Frontend den Reviver von
JSON.parseoder einen Parser mit Unterstützung großer Zahlen (etwajson-bigint) nutzen, um das Feld inBigIntzu verwandeln – beachten Sie aber, dassBigIntnicht direkt anJSON.stringifyübergeben werden kann.
2.2 Dezimalzahlen sind keine exakten Werte
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
Stellen Sie Geldbeträge stets als Ganzzahlen in der kleinsten Währungseinheit (Cent) oder als Strings dar; akkumulieren Sie sie niemals mit Gleitkommazahlen.
2.3 Fallen bei der Serialisierung von Zahlen
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 und NaN werden stillschweigend zu null – die am schwersten aufzuspürende Klasse von „die Daten sind einfach verschwunden“.
2.4 Standardverhalten nach Sprache
| Sprache | Standard-Zahlentyp | Fälle mit expliziter Behandlung |
|---|---|---|
| JavaScript | double | Große Ganzzahlen, BigInt-Serialisierung |
| Go | float64 (encoding/json) |
json.Number oder Decoder.UseNumber() zur Präzisionserhaltung |
| Java (Jackson) | int/long/double/BigDecimal aus dem Literal abgeleitet |
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS erzwingen |
| Python | int (beliebige Präzision) / float |
parse_float=Decimal zur Erhaltung der Dezimalpräzision |
| Rust (serde_json) | i64/u64/f64 |
Beliebige Präzision erfordert arbitrary_precision |
3. Strings: ein UTF-8-Byte-Stream, betrachtet durch UTF-16
3.1 Länge ist nicht gleich Zeichenzahl
In JavaScript ist "🎉".length gleich 2, weil Emojis in den supplementären Ebenen liegen und durch ein Surrogatpaar dargestellt werden.
Wenn der Server die Länge über „Zeichenzahl“ begrenzt, während das Frontend über length validiert, werden sich beide Seiten garantiert uneinig.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (including zero-width joiners)
Wenn Sie streng zählen müssen, verwenden Sie Intl.Segmenter (nach Graphem-Clustern) oder zählen Sie Unicode-Codepoints im Backend – und legen Sie die Definition klar in der API-Dokumentation fest.
3.2 Einzelne Surrogate
Ein alleinstehendes hohes Surrogat wie "\ud800" ist grammatisch gültiges JSON (\uXXXX akzeptiert beliebige vier Hexziffern), aber kein gültiger Unicode-Text.
Seit ES2019 escaped JSON.stringify einzelne Surrogate als \ud800 und garantiert so eine „wohlgeformte“ Ausgabe.
Beim sprachübergreifenden Transfer werden solche Werte abgelehnt, wenn die Gegenseite Unicode streng validiert.
3.3 \u2028 und \u2029
Zeilen- und Absatztrenner sind in JSON gültig, aber wenn man sie direkt in JavaScript-Quelltext oder ein <script>-Tag einbettet, entsteht ein Syntaxfehler.
Beim Inlining von JSON in HTML stets escapen oder auf das Parsen aus einem String mit JSON.parse umstellen.
3.4 Drei Ursachen für verstümmelten chinesischen Text
- Die Datei selbst ist GBK und wird als UTF-8 gelesen;
- Der Response-Header deklariert
charset=gbk, der Inhalt ist aber tatsächlich UTF-8; - Der Text durchläuft eine Middleware, die „in einen String dekodiert und dann erneut kodiert“, sodass der chinesische Text ein zweites Mal umkodiert wird.
Untersuchungsreihenfolge: zuerst die Bytes ansehen (xxd), dann den Response-Header, zuletzt die Middleware.
4. null versus „dieser Schlüssel existiert nicht“
In JSON sind diese beiden verschieden:
{"nickname": null}
{}
- Ersteres bedeutet „das Feld existiert, mit einem leeren Wert“;
- Letzteres bedeutet „das Feld existiert nicht“.
In TypeScript wird daraus der Unterschied zwischen string | null und nickname?: string; in Java der zwischen „explizites null“ und „Standardwert“.
Ohne vereinbarte Konvention entstehen diese Abweichungen:
// Frontend A's approach
if (data.nickname === undefined) { /* treat it as having no nickname */ }
// Frontend B's approach
if (!data.nickname) { /* handle null and the empty string together */ }
Empfehlung: Die API-Dokumentation sollte festlegen „bei fehlendem Wert einheitlich null verwenden, niemals den Schlüssel weglassen“ – oder das Gegenteil, aber nur eine Variante wählen;
bei der Aktualisierung einer API PATCH-Semantik nutzen, um die Mehrdeutigkeit „nicht gesendet oder als null gesendet“ zu vermeiden.
5. Arrays und Objekte
5.1 Arrays sind nicht „homogen“
[1, "two", {"three": 3}, null]
Grammatisch gültig, aber die Typdefinition wird sehr schwer zu schreiben. In APIs sollten Array-Elemente möglichst homogen bleiben; bei heterogenen Fällen auf ein Array aus Objekten mit einem type-Diskriminantenfeld umstellen.
5.2 Objektschlüssel sind ungeordnet, doch viele verlassen sich auf die Reihenfolge
Die Spezifikation bezeichnet ein Objekt als „eine ungeordnete Sammlung von Name/Wert-Paaren“. In der Praxis bewahrt V8 die Einfügereihenfolge (außer bei Ganzzahlschlüsseln, die sortiert werden). Logik auf die Schlüsselreihenfolge zu stützen (etwa „den ersten Schlüssel nehmen“) heißt, sich auf ein Implementierungsdetail zu verlassen.
5.3 Sparse Arrays und Lücken gibt es nicht
JavaScripts [1, , 3] serialisiert zu [1,null,3] – die Lücke wird zu null. JSON kennt kein Konzept eines „Slots“.
6. Eine Checkliste zum Umsetzen
- [ ] Alle IDs, Geldbeträge und übergroßen Ganzzahlen sind im API-Vertrag ausdrücklich Strings;
- [ ] Die API-Dokumentation legt die Semantik von
nullgegenüber einem weggelassenen Feld fest; - [ ] Längenlimits für Strings geben an, ob sie Codepoints oder UTF-16-Einheiten zählen;
- [ ]
Infinity/NaN/undefinedwerden vor der Serialisierung gefiltert oder ausdrücklich innullumgewandelt; - [ ] In HTML eingebettetes JSON wird escaped;
- [ ] Frontend und Backend haben je einen Vertragstestfall für „große Ganzzahl + Emoji + null“.
FAQ
Wie sollte ich Ganzzahlen oberhalb von 2^53 - 1 behandeln?
Am wenigsten Mühe bereitet es, Felder wie IDs über die gesamte Pipeline als Strings zu senden, zum Beispiel {"id": "9007199254740993"}. Auf dem Server lässt sich die Präzision mit Javas BigInteger / BigDecimal, Gos json.Number oder Pythons decimal.Decimal bewahren. Im Frontend kann ein Parser wie json-bigint das Feld in BigInt verwandeln – denken Sie aber daran, dass BigInt nicht direkt an JSON.stringify übergeben werden kann.
Warum werden Infinity und NaN nach der Serialisierung zu null?
Der JSON-Zahlentyp akzeptiert keine nicht-standardisierten Literale wie NaN und Infinity, deshalb wandelt JSON.stringify sie stillschweigend in null um, ohne Fehler. Das ist die am schwersten aufzuspürende Klasse von „die Daten sind einfach verschwunden“. Filtern Sie solche Werte ausdrücklich vor der Serialisierung oder wandeln Sie sie gemäß Ihrer Geschäftskonvention in null um und protokollieren Sie es.
Soll ich null senden oder das Feld ganz weglassen?
Beides funktioniert, aber die API-Dokumentation muss eine Variante wählen und klar festlegen, sonst nimmt jede Frontend-Instanz etwas anderes an. In den Beispielen entsprechen {"nickname": null} und {} den TypeScript-Typen string | null und nickname?: string. Nutzen Sie beim Aktualisieren einer API PATCH-Semantik, um die Mehrdeutigkeit „nicht gesendet oder als null gesendet“ zu vermeiden.