~/blog

parseInt(0.0000005) is 5, because parseInt reads a string

published

#javascript#numbers#parsing

TL;DR

parseInt is a string parser. Hand it a number and it calls String() on it first, then reads digits until the first character that is not one. Very small and very large numbers stringify in exponent notation, so parseInt(0.0000005) reads "5e-7" and returns 5, and parseInt(1e21) reads "1e+21" and returns 1. To drop the fraction from a number, use Math.trunc. To turn a string into an integer, pass a radix and validate the whole string, because parseInt('12px') happily returns 12.

The problem

parseInt gets used as a shorthand for “drop the decimals”, and it works on every value you try by hand:

for (const x of [12.9, 0.5, 0.000005, 0.0000005, 1e20, 1e21, -0.0000004]) {
  console.log(String(x).padEnd(24), 'parseInt ->', String(parseInt(x)).padEnd(22), 'Math.trunc ->', Math.trunc(x))
}
12.9                     parseInt -> 12                     Math.trunc -> 12
0.5                      parseInt -> 0                      Math.trunc -> 0
0.000005                 parseInt -> 0                      Math.trunc -> 0
5e-7                     parseInt -> 5                      Math.trunc -> 0
100000000000000000000    parseInt -> 100000000000000000000  Math.trunc -> 100000000000000000000
1e+21                    parseInt -> 1                      Math.trunc -> 1e+21
-4e-7                    parseInt -> -4                     Math.trunc -> -0

Four rows agree. Then a value that should truncate to zero comes back as 5, a negative one comes back as -4, and 1e21 comes back as 1. The first column already shows why: those three numbers print in exponent form. (The -0 in the last row is Math.trunc keeping the sign of a negative input; the caveats come back to it.)

The small end is the dangerous one, because small values are ordinary in real code: a progress ratio, a probability, a difference between two timestamps in seconds, a price delta after division. Nothing about the code changes between the row that works and the row that returns 5.

Why it happens

MDN states the first step plainly: “The parseInt function converts its first argument to a string, parses that string, then returns an integer or NaN.” And: “If parseInt encounters a character in the input string that is not a valid numeral in the specified radix, it ignores it and all succeeding characters and returns the integer value parsed up to that point.”

So the result depends on how the number prints. JavaScript switches to exponent notation for numbers whose magnitude is below 1e-6 or at least 1e21:

for (const x of [1e-6, 9.99e-7, 1.5e-7, 1e20, 1e21]) {
  console.log(String(x).padEnd(24), '-> parseInt', parseInt(x))
}
0.000001                 -> parseInt 0
9.99e-7                  -> parseInt 9
1.5e-7                   -> parseInt 1
100000000000000000000    -> parseInt 100000000000000000000
1e+21                    -> parseInt 1

9.99e-7 becomes "9.99e-7", and parsing stops at the ., giving 9. Every number in those two ranges collapses to its first significant digit.

The same “parse a prefix, ignore the rest” rule is behind the string cases that surprise people:

console.log(parseInt('12px'))   // 12
console.log(parseInt('1,234'))  // 1
console.log(parseInt('1e3'))    // 1
console.log(parseInt('0x1A'))   // 26
console.log(parseInt('0b101'))  // 0
console.log(parseInt(''))       // NaN

'1e3' is a thousand to Number and one to parseInt. Without a radix, parseInt recognizes exactly one prefix; MDN again: “The only prefix that parseInt() recognizes is 0x or 0X for hexadecimal values — everything else is parsed as a decimal value if radix is missing.” So '0x1A' silently becomes 26 while '0b101' stops at the b and becomes 0.

The map(parseInt) variant

The radix being the second parameter produces the other famous result:

console.log(['10', '10', '10', '10'].map(parseInt))
[ 10, NaN, 2, 3 ]

map calls its callback with (element, index, array), so this is parseInt('10', 0), parseInt('10', 1), parseInt('10', 2) and parseInt('10', 3). A radix of 0 falls back to 10, radix 1 is out of range and gives NaN, and '10' in base 2 and base 3 is 2 and 3. Wrap the call so only the string goes in:

console.log(['10', '10', '10', '10'].map((s) => parseInt(s, 10)))
[ 10, 10, 10, 10 ]

What to do

For numbers, use Math.trunc. It works on the number itself, so it has no string step to go wrong. MDN is direct about it: “parseInt should not be used as a substitute for Math.trunc().” If you meant rounding rather than dropping the fraction, Math.floor, Math.ceil and Math.round are the tools, and they are not interchangeable with it: Math.floor matches Math.trunc only on non-negative numbers (Math.floor(-1.5) is -2), Math.ceil only on non-positive ones, and Math.round goes to the nearest integer.

For strings, always pass the radix. parseInt(s, 10) turns off the 0x guess (parseInt('0x1A', 10) is 0) and keeps the call safe when someone passes it to map.

When the whole string must be a number, validate it first. parseInt accepts a numeric prefix, which is the wrong contract for form fields, query parameters and config values. Number is stricter about trailing junk, but it has its own traps: Number('') and Number(' ') are both 0, Number('1e3') is 1000, and Number('0x1A') is 26. A small helper that says exactly what it accepts avoids all of them:

function parseIntStrict(input) {
  const s = String(input).trim()
  if (!/^[+-]?\d+$/.test(s)) return NaN
  const n = Number(s)
  return Number.isSafeInteger(n) ? n : NaN
}

for (const v of ['42', ' 42 ', '-7', '+3', '08', '12px', '1e3', '', '0x1A', '1,234', '9007199254740993']) {
  console.log(JSON.stringify(v).padEnd(20), parseIntStrict(v))
}
"42"                 42
" 42 "               42
"-7"                 -7
"+3"                 3
"08"                 8
"12px"               NaN
"1e3"                NaN
""                   NaN
"0x1A"               NaN
"1,234"              NaN
"9007199254740993"   NaN

The last row is the Number.isSafeInteger check doing its job. parseInt('9007199254740993') returns 9007199254740992 without complaint, because the value is past Number.MAX_SAFE_INTEGER and cannot be represented exactly. If you need integers that large, parse them with BigInt instead.

Caveats

References