JSON Toolkit

שישה טיפוסים, דיוק מספרים ומלכודות קידוד

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

מתחילכ-12 דקות קריאהפורסם 2026-10-30עודכן 2026-10-30
תמונה עבור שישה טיפוסים, דיוק מספרים ומלכודות קידוד

שורה תחתונה: בדקו קודם אם שדות 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) ייתקל בזה.

תיקונים (לפי סדר ההמלצה שלי):

  1. שימוש במחרוזות עבור ID לאורך כל הצינור: {"id": "9007199254740993"} — הכי פחות צרות מכולן;
  2. פענוח עם טיפוסים בעלי דיוק גבוה בצד השרת: BigInteger / BigDecimal של Java, json.Number של Go, decimal.Decimal של Python;
  3. בצד הלקוח, השתמשו ב-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 שלושה מקורות לטקסט סיני משובש

  1. הקובץ עצמו ב-GBK ונקרא כ-UTF-8;
  2. כותרת התגובה מצהירה charset=gbk, אבל התוכן למעשה UTF-8;
  3. הוא עובר דרך 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".