شش نوع، دقت اعداد و دامهای رمزگذاری
جمعبندی: JSON فقط شش نوع دارد، اما هر یک از آنها میتواند داده شما را بیصدا تغییر دهد. این مقاله دامهای اعداد، رشتهها و null را یکجا پوشش میدهد.

جمعبندی: نخست بررسی کنید آیا فیلدهای شناسه یا مبلغ از 2^53 - 1 فراتر میروند یا به دقت اعشاری زیادی نیاز دارند، سپس بررسی کنید آیا محدودیت طول رشته بر اساس code point شمرده میشود یا واحد UTF-16؛ هر مقداری بیرون از بازه عدد صحیح ایمن را بهصورت رشته حمل کنید و به تجزیهکننده اعتماد نکنید که دقت را حفظ کند.
JSON نوعهای کمتری از آنچه بیشتر افراد تصور میکنند دارد و دامهای بیشتری: عددها جابهجا میشوند، رشتهها میشکنند و null چیزی متفاوت از کلید غایب است.
۱. چشمانداز انواع
| نوع | نمونه لیترال | محدودیت در 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 |
به دو چیزی که وجود ندارند توجه کنید: نوع تاریخ وجود ندارد و نوع عدد صحیح هم وجود ندارد. همه تاریخها رشتهاند و همه عددهای صحیح و ممیز شناور در یک نوع واحد «number» شریکاند.
۲. عددها: دقت مضاعف پاسخ پیشفرض است
۲.۱ مرز عدد صحیح ایمن
بزرگترین عدد صحیحی که دقت مضاعف IEEE 754 میتواند دقیق نمایش دهد 2^53 - 1 = 9007199254740991 است. فراتر از آن، میان عددهای صحیح مجاور شکاف ایجاد میشود:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
این مسئله JSON نیست؛ مسئله مرحله «متن → دقت مضاعف» است. هر خط لولهای که JavaScript در میانه آن باشد (gateway، BFF، فرانتاند، سرویس Node) به آن برخورد میکند.
راهحلها (به ترتیب میزان توصیه من):
۱. برای شناسهها در تمام خط لوله از رشته استفاده کنید: {"id": "9007199254740993"}، کمدردسرترین گزینه؛
۲. در سرور با نوعهای دقت بالا تجزیه کنید: BigInteger / BigDecimal جاوا، json.Number در Go، decimal.Decimal در Python؛
۳. در فرانتاند از reviver مربوط به JSON.parse یا تجزیهکنندهای که اعداد بزرگ را پشتیبانی میکند (مانند json-bigint) استفاده کنید تا فیلد به BigInt تبدیل شود، اما توجه کنید که BigInt را نمیتوان مستقیم به JSON.stringify سپرد.
۲.۲ اعشار مقدار دقیق نیست
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
مبلغهای پولی را همیشه بهصورت عدد صحیح در کوچکترین واحد پول (سنت) یا بهصورت رشته نمایش دهید؛ هرگز آنها را با ممیز شناور جمع نکنید.
۲.۳ دامهای عدد در سریالسازی
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 تبدیل میشوند و همین دشوارترین دسته «داده بهتازگی ناپدید شد» برای ردیابی است.
۲.۴ رفتار پیشفرض بر اساس زبان
| زبان | نوع پیشفرض عدد | مواردی که نیاز به مدیریت صریح دارند |
|---|---|---|
| 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 است |
۳. رشتهها: جریان بایتی UTF-8 که از دریچه UTF-16 دیده میشود
۳.۱ طول با تعداد کاراکتر یکی نیست
در JavaScript، "🎉".length برابر 2 است، چون emoji در صفحههای تکمیلی زندگی میکند و با یک جفت جانشین نمایش داده میشود.
اگر سرور طول را بر اساس «تعداد کاراکتر» محدود کند و فرانتاند با length اعتبارسنجی کند، اختلاف دو طرف تضمینشده است.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (including zero-width joiners)
وقتی به شمارش دقیق نیاز دارید، از Intl.Segmenter (بر اساس خوشه گرافمی) استفاده کنید یا در بکاند code point یونیکد را بشمارید و تعریف را صریحاً در مستندات API بنویسید.
۳.۲ جانشینهای تنها
یک جانشین بالای تنها مانند "\ud800" از نظر دستور زبان JSON معتبر است (\uXXXX هر چهار رقم هگز را میپذیرد)، اما متن یونیکد معتبر نیست.
از ES2019 به بعد، JSON.stringify جانشینهای تنها را بهصورت \ud800 escape میکند و تضمین میکند خروجی «خوشساخت» است.
هنگام انتقال میان زبانها، اگر طرف مقابل یونیکد را سختگیرانه اعتبارسنجی کند، چنین مقادیری رد میشوند.
۳.۳ \u2028 و \u2029
جداکنندههای خط و پاراگراف در JSON معتبرند، اما جاسازی مستقیم آنها در کد منبع JavaScript یا تگ <script> خطای نحوی ایجاد میکند.
هنگام درونخط کردن JSON در HTML، همیشه آنها را escape کنید یا با JSON.parse از رشته تجزیه کنید.
۳.۴ سه منبع متن چینی آشوب
۱. خود فایل GBK است و بهصورت UTF-8 خوانده میشود؛
۲. هدر پاسخ charset=gbk اعلام میکند، اما محتوا در واقع UTF-8 است؛
۳. از میانافزاری عبور میکند که «به رشته رمزگشایی و دوباره رمزگذاری میکند» و متن چینی بار دوم تغییر رمز مییابد.
ترتیب بررسی: نخست بایتها را ببینید (xxd)، سپس هدر پاسخ و در آخر میانافزار.
۴. null در برابر «این کلید وجود ندارد»
در JSON این دو متفاوتاند:
{"nickname": null}
{}
- اولی یعنی «فیلد وجود دارد، با مقدار خالی»؛
- دومی یعنی «فیلد وجود ندارد».
در TypeScript این به تفاوت string | null و nickname?: string تبدیل میشود؛ در Java به تفاوت «null صریح» و «مقدار پیشفرض».
بدون یک قرارداد توافقشده، این شکافها ظاهر میشوند:
// 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 */ }
توصیه: مستندات API باید بگوید «وقتی مقدار غایب است بهطور یکسان از null استفاده کنید و هرگز کلید را حذف نکنید» یا برعکس — اما فقط یکی را انتخاب کنید؛
هنگام بهروزرسانی API از معنای PATCH استفاده کنید تا ابهام «فرستاده نشد یا بهصورت null فرستاده شد» از میان برود.
۵. آرایهها و objectها
۵.۱ آرایهها «همگون» نیستند
[1, "two", {"three": 3}, null]
از نظر دستور زبان معتبر است، اما نوشتن تعریف نوع بسیار دشوار میشود. در APIها بکوشید عناصر آرایه همگون بمانند؛ برای موارد ناهمگون به آرایهای از objectها با فیلد تمایزدهنده type روی آورید.
۵.۲ کلیدهای object بیترتیباند، اما بسیاری به ترتیب وابستهاند
استاندارد میگوید object «مجموعهای بیترتیب از جفتهای نام/مقدار» است. در عمل V8 ترتیب درج را حفظ میکند (بهجز کلیدهای عدد صحیح که مرتب میشوند). ساختن منطق بر اساس ترتیب کلیدها (مثلاً گرفتن «کلید نخست») یعنی وابستگی به جزئیات پیادهسازی.
۵.۳ آرایههای تُنُک و حفرهها وجود ندارند
در JavaScript، [1, , 3] به [1,null,3] سریال میشود — حفره به null تبدیل میشود. JSON مفهومی به نام «جای خالی» ندارد.
۶. چکلیستی که میتوانید اجرا کنید
- [ ] همه شناسهها، مبلغها و عددهای صحیح بزرگتر از حد در قرارداد API صریحاً رشتهاند؛
- [ ] مستندات API معنای
nullدر برابر فیلد حذفشده را روشن میکند؛ - [ ] محدودیت طول رشته مشخص میکند code point شمرده میشود یا واحد UTF-16؛
- [ ] پیش از سریالسازی
Infinity/NaN/undefinedفیلتر میشوند یا صریحاً بهnullتبدیل میگردند؛ - [ ] JSON درونخطشده در HTML escape میشود؛
- [ ] فرانتاند و بکاند هر یک یک مورد آزمون قرارداد برای «عدد صحیح بزرگ + emoji + null» دارند.
پرسشهای متداول
عددهای صحیح بزرگتر از 2^53 - 1 را چگونه مدیریت کنم؟
کمدردسرترین راه این است که فیلدهایی مانند شناسه را در تمام خط لوله بهصورت رشته بفرستید، مثلاً {"id": "9007199254740993"}. در سرور میتوانید دقت را با BigInteger / BigDecimal جاوا، json.Number در Go یا decimal.Decimal در Python حفظ کنید. در فرانتاند، تجزیهکنندهای مانند json-bigint میتواند فیلد را به BigInt تبدیل کند، اما یادتان باشد BigInt را نمیتوان مستقیم به JSON.stringify سپرد.
چرا Infinity و NaN پس از سریالسازی null میشوند؟
نوع عدد JSON لیترالهای غیراستانداردی مانند NaN و Infinity را نمیپذیرد، بنابراین JSON.stringify آنها را بیصدا و بدون هیچ خطایی به null تبدیل میکند. همین دشوارترین دسته «داده بهتازگی ناپدید شد» برای ردیابی است. این مقادیر را پیش از سریالسازی صریحاً فیلتر کنید یا بر اساس قرارداد کسبوکار به null تبدیل و ثبتشان کنید.
null بفرستم یا فیلد را کاملاً حذف کنم؟
هر دو کار میکند، اما مستندات API باید یکی را انتخاب و صریح بیان کند، وگرنه هر فرانتاندی فرض دیگری خواهد داشت. در مثالها، {"nickname": null} و {} بهترتیب به string | null و nickname?: string در TypeScript نگاشت میشوند. هنگام بهروزرسانی API از معنای PATCH استفاده کنید تا ابهام «فرستاده نشد یا بهصورت null فرستاده شد» از میان برود.