跪拜 Guibai
← Back to the summary

Five JavaScript Number Traps That Break Production Without a Single Error

banner

0.1 + 0.2 !== 0.3, everyone knows this meme. But honestly, it's the most harmless of JavaScript's number traps—at least you know it exists.

What's truly frightening are the ones that don't error, don't throw exceptions, don't show any red in the console, yet quietly change the money you calculated, the data you queried, or the page number you skipped to. I've actually stepped on these five and paid the price. All the code in this article can be reproduced by copying it into the Chrome console.

📌 toFixed is not rounding, it's a gamble

The first time I stepped on this was displaying prices. A product's unit price after tax was 1.005 yuan, displayed with two decimal places:

(1.005).toFixed(2); // "1.00" —— not 1.01?
(0.615).toFixed(2); // "0.61" —— rounds down again?
(1.35).toFixed(1);  // "1.4"  —— but this one rounds up

The same method sometimes rounds down, sometimes rounds up, looking like a random bug. It's not.

The reason: the object toFixed rounds is not "the decimal number you think it is," but the actual binary floating-point value stored in memory for that number. 1.005 stored is actually 1.00499999..., so of course it rounds to 1.00; the actual stored value of 1.35 is slightly larger than 1.35, so it rounds up to 1.4. The result of toFixed depends on which side of the midpoint the tail of the binary representation falls on, and has nothing to do with the decimal literal you wrote.

The common "fix" online is to multiply by 100 first and then use Math.round, which also fails:

Math.round(1.005 * 100) / 100; // 1, not 1.01
// because 1.005 * 100 = 100.49999999999999

Also, toFixed returns a string, and using it directly in calculations causes another implicit conversion.

The correct approach for the presentation layer is Intl.NumberFormat, which handles display semantics and gives stable results:

const fmt = new Intl.NumberFormat('zh-CN', {
  style: 'currency',
  currency: 'CNY',
});
fmt.format(1.005); // "¥1.01"
fmt.format(0.615); // "¥0.62"

But remember: formatting only solves "display." Whenever calculations involve money, the source must not be floating-point numbers; see trap five.

🛠️ 19-digit IDs silently lose precision in JSON.parse

The backend returns a snowflake-generated order ID, 19 digits:

Number.MAX_SAFE_INTEGER;            // 9007199254740991, 16 digits
JSON.parse('{"id": 9007199254740993}').id;
// 9007199254740992 —— the last digit was rewritten, with no warning

JS's number is an IEEE754 double-precision float, and the safe integer limit is 2^53 - 1. For integers beyond this range, the difference between two adjacent representable numbers is 2 or more, and the trailing digits are swallowed at the moment of parsing.

The online symptoms are very confusing: the detail page uses the ID to query the API and gets "order does not exist"; copying the ID out is correct, but running it in the program is wrong.

There are only three paths to a fix:

  1. The backend serializes the ID field as a string—the cleanest way, the frontend treats it as a string throughout, which is now the default practice for most major company APIs;
  2. The frontend receives it using BigInt, but note that BigInt cannot be directly JSON.stringify'd (it throws a TypeError), and must be converted back to a string during serialization;
  3. If the backend cannot be changed, use a JSON parser that supports large numbers at the request layer (like json-bigint) to read out-of-range numbers as strings.

🔍 NaN's type is number, and it's not equal to itself

typeof NaN;        // "number"
NaN === NaN;       // false
Number('');        // 0
Number(undefined); // NaN

These four facts together form a complete online incident chain. A real scenario I stepped on: a quantity input field in a form was left blank by the user, Number('') returned 0—"not filled in" was silently translated to "quantity is 0," then participated in a multiplication chain, the entire amount was calculated as 0, the page displayed ¥0.00, with no error at all.

Even more troublesome is finding NaN: indexOf can't find it (because NaN !== NaN), only includes can. Once NaN gets into an array or an accumulated result, it pollutes all subsequent calculations like a virus.

Use a unified validation function as a safety net, noting that empty strings must be blocked separately—Number.isFinite(0) is true, so relying on it alone won't stop it:

function toSafeNumber(v, fallback = 0) {
  if (typeof v === 'string' && v.trim() === '') return fallback;
  const n = Number(v);
  return Number.isFinite(n) ? n : fallback;
}

toSafeNumber('');         // 0 (fallback)
toSafeNumber(undefined);  // 0
toSafeNumber('12px');     // 0, not 12
toSafeNumber('3.5');      // 3.5

✅ parseInt(0.0000005) === 5

This is the most counter-intuitive one in my opinion:

parseInt(0.0000005); // 5
Math.round(-0.5);    // -0
-7 % 3;              // -1

Three seemingly unrelated phenomena all stem from "JS's number rules don't match intuition":

parseInt's argument is first converted to a string. 0.0000005 converted to a string is "5e-7", parseInt parses from the beginning and stops at e, thus getting 5. So "using parseInt to truncate" is itself wrong; for truncation use Math.trunc or Math.floor.

Math.round's rounding direction is towards +∞, so -0.5 rounds to -0 instead of -1. -0 behaves normally in most scenarios, but Object.is(x, -0) can distinguish it, and in extreme cases (like using it as a divisor) it will explode into -Infinity.

The modulo operator preserves the sign of the dividend, -7 % 3 = -1. This is most likely to bite when writing carousels, pagination, or circular list indices: stepping the index back one step out of bounds becomes a negative number, and arr[-1] gets undefined. The standard way to write a circular index is:

const idx = ((i % n) + n) % n;

📊 Errors accumulate in loops, and splitting bills is always off by one cent

Everyone knows a single 0.1 + 0.2 is inaccurate, but what's more insidious is the accumulation effect:

[0.1, 0.2, 0.3].reduce((a, b) => a + b) === 0.6; // false
(100 / 3).toFixed(2) * 3;                          // 99.99

Every floating-point operation carries a tiny error, and errors accumulate during loop summation. Bills that don't match, split payments that are off by one cent, progress bars that never reach 100%—many of these are rooted here. Splitting 100 yuan three ways, 33.33 × 3 = 99.99, the remaining one cent is mathematically unsolvable—one person must take more.

For calculations involving money, don't touch floating-point numbers in the entire chain:

// Always store and calculate amounts using integer 'cents'
function splitCents(cents, n) {
  const base = Math.floor(cents / n);
  const rest = cents - base * n;
  // Distribute the remainder one cent at a time to the first few people, the total never changes
  return Array.from({ length: n }, (_, i) =>
    base + (i < rest ? 1 : 0)
  );
}
splitCents(10000, 3); // [3334, 3333, 3333], sums exactly to 10000

Use integer cents at the source, and format with Intl.NumberFormat at the display layer; this is the standard practice for e-commerce and finance frontends.

Quick Reference: 5 Traps at a Glance (worth bookmarking)

Trap Phenomenon Root Cause Fix
toFixed unstable rounding (1.005).toFixed(2) = "1.00" Rounds the binary stored value, not the literal Use Intl.NumberFormat for display, don't use toFixed for calculations
Long ID precision loss 19-digit ID's last digit changes after JSON.parse Exceeds the 2^53-1 safe integer limit Backend returns string / BigInt / json-bigint
NaN silent propagation Empty input becomes 0 and participates in calculations Number('') = 0, NaN is not equal to itself Unified toSafeNumber validation, block empty strings separately
parseInt implicit conversion parseInt(0.0000005) = 5 Argument is first converted to string "5e-7" Use Math.trunc / Math.floor for integer truncation
Floating-point accumulation error Split bills off by one cent, totals don't match Errors from each operation accumulate Use integer cents throughout, distribute remainder method

⚙️ If you need a library for money

I pulled the latest status of several mainstream number libraries (as of this writing):

Library Star Last Maintained Positioning
decimal.js 7.2K July this year Arbitrary-precision decimal operations, most feature-complete
dinero.js 6.8K Last week Designed specifically for currency, supports exchange rates/splitting/formatting
big.js 5.2K Last year Lightweight four arithmetic operations, sufficient and small
currency.js 3.4K Last week Lightweight currency calculations, intuitive API

Selection advice: if it's just display formatting, Intl.NumberFormat is enough; if the amount calculations aren't complex, the integer cents solution with zero dependencies is the most stable; if you need to handle exchange rates, multi-currency, or complex splitting, go straight to dinero.js or decimal.js, don't reinvent the wheel.

🎯 Final Words

These 5 traps have one thing in common: none of them throw errors. JS won't tell you an ID was truncated, a cent was lost, or a quantity became 0; it will just quietly hand you the wrong result and wait for users to discover it for you in the production environment.

The defense isn't complicated: treat all IDs as strings; treat all money as integer cents; validate all user input before it participates in calculations. Making these three rules muscle memory will block the vast majority of number-related incidents.

Which of these 5 have you stepped on? Or did I miss an even more insidious one? Let's chat in the comments—I'll go first, parseInt(0.0000005) === 5 is the one where I stared at the output for a full ten seconds.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

增量编译

Use BigInt on the frontend to receive it, but note that BigInt can't be directly JSON.stringify'd (it throws a TypeError); convert it back to a string during serialization. BigInt.prototype.toJSON = function () { return JSON.rawJSON(this.toString()) } ;console.log(JSON.stringify(12345678901234567890n))

kyriewen

👍