שישה טיפוסים, דיוק מספרים ומלכודות קידוד
שורה תחתונה: ל-JSON יש רק שישה טיפוסים, אבל כל אחד מהם יכול לעצב מחדש את הנתונים שלכם בשקט. מאמר זה מכסה במעבר אחד את המלכודות של מספרים, מחרוזות ו-null.

שורה תחתונה: בדקו קודם אם שדות ID או כסף חורגים מ-2^53 - 1 או זקוקים לדיוק של ספרות עשרוניות רבות, ואז בדקו אם מגבלות המחרוזת סופרות code points או יחידות UTF-16; כל ערך שמעבר לטווח השלמים הבטוח יש להעביר כמחרוזת במקום לסמוך על המפענח שישמור על הדיוק.
ל-JSON יש פחות טיפוסים ממה שרוב האנשים חושבים, ויותר מלכודות: מספרים זזים, מחרוזות נשברות, ו-null הוא דבר שונה ממפתח חסר.
1. מפת הטיפוסים
| טיפוס | דוגמת literal | הגבלה ב-JSON | מיפוי נפוץ |
|---|---|---|---|
| אובייקט | {"a": 1} |
המפתחות חייבים להיות מחרוזות, הערכים יכולים להיות כל דבר | Object / Map / struct |
| מערך | [1, "a", null] |
האיברים יכולים להיות מסוגים שונים | Array / List / slice |
| מחרוזת | "text" |
UTF-8, גרשיים כפולים | String |
| מספר | 1, -1.5, 1e9 |
ללא אפסים מובילים, ללא +, ללא NaN |
double / Decimal |
| בוליאני | 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 באמצע (gateway, BFF, frontend, שירות Node) ייתקל בזה.
תיקונים (לפי סדר ההמלצה שלי):
- שימוש במחרוזות עבור ID לאורך כל הצינור:
{"id": "9007199254740993"}— הכי פחות צרות מכולן; - פענוח עם טיפוסים בעלי דיוק גבוה בצד השרת:
BigInteger/BigDecimalשל Java,json.Numberשל Go,decimal.Decimalשל Python; - בצד הלקוח, השתמשו ב-reviver של
JSON.parseאו במפענח שתומך במספרים גדולים (כמו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 לפי ה-literal |
הכריחו DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
| Python | int (דיוק בלתי מוגבל) / float |
השתמשו ב-parse_float=Decimal כדי לשמור על דיוק עשרוני |
| Rust (serde_json) | i64/u64/f64 |
דיוק בלתי מוגבל מחייב הפעלת arbitrary_precision |
3. מחרוזות: זרם בתים ב-UTF-8 הנצפה דרך UTF-16
3.1 אורך אינו זהה למספר תווים
ב-JavaScript, "🎉".length הוא 2, כי אמוג'י נמצאים במישורים המשלימים ומיוצגים על ידי זוג surrogate.
אם השרת מגביל אורך לפי "מספר תווים" והצד הלקוח מאמת לפי length, שני הצדדים בהכרח לא יסכימו.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (כולל zero-width joiners)
כשנדרש ספירה קפדנית, השתמשו ב-Intl.Segmenter (לפי grapheme cluster) או ספרו Unicode code points בצד השרת, והצהירו על ההגדרה בבירור בתיעוד ה-API.
3.2 surrogates בודדים
high surrogate בודד כמו "\ud800" הוא JSON תקין מבחינת דקדוק (\uXXXX מקבל כל ארבע ספרות hex), אבל הוא אינו טקסט Unicode תקין.
מאז ES2019, JSON.stringify מבצע escape ל-surrogates בודדים בתור \ud800, ובכך מבטיח שהפלט יהיה "well-formed".
בהעברה בין שפות, ערכים כאלה יידחו אם הצד השני מאמת Unicode בקפדנות.
3.3 \u2028 ו-\u2029
מפרידי שורה ומפרידי פסקה תקינים ב-JSON, אבל הטמעתם ישירות בקוד מקור JavaScript או בתג <script> גורמת לשגיאת תחביר.
בעת הטמעת JSON בתוך HTML, תמיד בצעו להם escape או עברו לפענוח ממחרוזת עם JSON.parse.
3.4 שלושה מקורות לטקסט סיני משובש
- הקובץ עצמו ב-GBK ונקרא כ-UTF-8;
- כותרת התגובה מצהירה
charset=gbk, אבל התוכן למעשה UTF-8; - הוא עובר דרך middleware ש"מפענח למחרוזת ואז מקודד מחדש", כך שהטקסט הסיני עובר טרנסקודינג שני.
סדר החקירה: הסתכלו קודם על הבתים (xxd), אחר כך על כותרת התגובה, ולבסוף על ה-middleware.
4. null לעומת "המפתח הזה לא קיים"
ב-JSON שני אלה שונים:
{"nickname": null}
{}
- הראשון אומר "השדה קיים, עם ערך ריק";
- השני אומר "השדה לא קיים".
ב-TypeScript זה הופך להבדל בין string | null ל-nickname?: string; ב-Java זה הופך להבדל בין "null מפורש" ל"ערך ברירת מחדל".
בלי מוסכמה מוסכמת, נוצרים הפיצולים האלה:
// הגישה של Frontend A
if (data.nickname === undefined) { /* להתייחס לזה כאל היעדר כינוי */ }
// הגישה של Frontend 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 מערכים דלילים וחורים אינם קיימים
[1, , 3] של JavaScript מסריאליזציה ל-[1,null,3] — החור הופך ל-null. ל-JSON אין מושג של "משבצת".
6. רשימת בדיקה שאפשר לפעול לפיה
- [ ] כל ה-ID, סכומי הכסף והשלמים הגדולים מדי מוגדרים במפורש כמחרוזות בחוזה ה-API;
- [ ] תיעוד ה-API מציין את הסמנטיקה של
nullלעומת שדה מושמט; - [ ] מגבלות אורך מחרוזת מציינות אם הן סופרות code points או יחידות UTF-16;
- [ ] מסננים החוצה
Infinity/NaN/undefinedלפני הסריאליזציה, או ממירים אותם במפורש ל-null; - [ ] JSON המוטמע בתוך HTML עובר escape;
- [ ] לצד הלקוח ולצד השרת יש מקרה בדיקה חוזי המכסה "מספר שלם גדול + אמוג'י + null".
שאלות נפוצות
איך לטפל בשלמים מעל 2^53 - 1?
הגישה הפחות מציקה היא לשלוח שדות כמו ID כמחרוזות לאורך כל הצינור, למשל {"id": "9007199254740993"}. בצד השרת אפשר לשמור על הדיוק עם BigInteger / BigDecimal של Java, json.Number של Go או decimal.Decimal של Python. בצד הלקוח, מפענח כמו json-bigint יכול להפוך את השדה ל-BigInt, אבל זכרו שאין להעביר BigInt ישירות ל-JSON.stringify.
למה Infinity ו-NaN הופכים ל-null אחרי סריאליזציה?
טיפוס המספר של JSON אינו מקבל literal לא-תקניים כמו NaN ו-Infinity, ולכן JSON.stringify ממיר אותם בשקט ל-null בלי שום שגיאה. זו הסוגיה הקשה ביותר לאיתור של "הנתונים פשוט נעלמו". סננו את הערכים האלה במפורש לפני הסריאליזציה, או המירו אותם ל-null לפי המוסכמה העסקית שלכם ותעדו זאת בלוג.
עדיף לשלוח null או להשמיט את השדה לגמרי?
שתי הדרכים עובדות, אבל תיעוד ה-API חייב לבחור באחת ולהצהיר עליה בבירור, אחרת כל צד לקוח יניח משהו אחר. בדוגמאות, {"nickname": null} ו-{} ממופים ל-string | null ול-nickname?: string ב-TypeScript. השתמשו בסמנטיקת PATCH בעת עדכון API כדי להימנע מהעמימות של "האם זה לא נשלח, או נשלח כ-null".