OneBug All write-ups

JavaScript ·

map(parseInt) returns NaN - the array index becomes radix

map passes (value, index) and parseInt reads the index as a radix, so clean numeric strings come back NaN or wrong. Two safe ways to write it.

Test yourself before reading - the write-up below gives it away.

Spot the bug JavaScript
1
// Parse the quantity column from a CSV row.
2
const raw = ["10", "7", "11", "3"];
3
const quantities = raw.map(parseInt);
4
console.log(quantities);
5
// expected: [ 10, 7, 11, 3 ]
6
// actual:   [ 10, NaN, 3, NaN ]  (!)

The NaN you can explain is not the scary one

Four clean numeric strings go in. What comes out is worse than garbage - it is half right.

// Parse the quantity column from a CSV row.
const raw = ["10", "7", "11", "3"];
const quantities = raw.map(parseInt);
console.log(quantities);
// expected: [ 10, 7, 11, 3 ]
// actual:   [ 10, NaN, 3, NaN ]  (!)

The NaNs at least announce themselves. The 3 in the third slot is a valid number that is simply not the number in the string.

Why does map(parseInt) return NaN?

map calls its callback with three arguments - (value, index, array) - and parseInt accepts two: (string, radix). Passed directly, the element index lands in the radix slot, so each string gets parsed in a different number base. The fix: give map a callback with exactly the shape you mean, raw.map(s => parseInt(s, 10)).

Walk it: one element, one accidental radix

Line 2, the input, is where a reasonable person looks first - bad strings are the usual cause of NaN. These strings are perfect. Line 3 is the bug, and it decodes element by element:

  • parseInt("10", 0) - a radix of 0 (or undefined) means "auto-detect", which is 10 unless the string starts with 0x. Result: 10. Looks fine.
  • parseInt("7", 1) - base 1 is not a positional number system; MDN's parseInt page specifies valid radixes as 2 through 36, everything else returns NaN.
  • parseInt("11", 2) - base 2. The string "11" is valid binary, so you get 3. A confident, wrong integer.
  • parseInt("3", 3) - base 3 has digits 0-2. The digit 3 is out of range: NaN.

The index was never meant as data, but parseInt has no way to know that. And because index 0 always auto-detects, the first element of any array parses correctly - which is precisely the element a quick console check inspects.

From CSV row to a total of NaN

An import script maps quantity columns exactly like this. The spot check on the first row - first element, index 0 - passes. In the aggregate step, one NaN poisons the sum (10 + NaN is NaN), so the daily total renders as blank and someone notices within a day. The uglier failure mode is a row where every accidental radix happens to produce a number, like "11" becoming 3: no NaN, no blank, a total that is merely wrong. There is nothing to grep in the output of that one; you find it when inventory disagrees with the report.

The fix - say the radix, or pick the right function

const quantities = raw.map(s => parseInt(s, 10));

The arrow gives map a unary callback, and the explicit 10 removes the auto-detect edge cases for good. When the values are plain decimals, map(Number) also works - Number ignores extra arguments, so passing it point-free is safe.

But swapping in Number blindly is the half-fix to watch: the two functions disagree on dirty input. parseInt("10px", 10) is 10; Number("10px") is NaN. And in the other direction Number("") is 0, while parseInt("", 10) is NaN - an empty CSV cell turning into a zero quantity can be a worse outcome than a loud NaN. Pick by contract: parseInt for "take the leading integer", Number for "this must be a well-formed number".

How to find point-free parseInt in your codebase

  • grep -rnE '\.(map|forEach|filter)\(parseInt\)' --include='*.{js,ts,jsx,tsx}' . - the exact shape, plus its siblings.
  • ESLint's core radix rule requires an explicit radix on every parseInt call, which kills this bug as a class. unicorn/no-array-callback-reference goes further and bans passing named functions to array methods altogether.
  • The general audit: any function passed point-free to map deserves one glance at its signature. If it has an optional second parameter, you just called it with the index.

Related bugs

JavaScript defaults betraying tidy-looking code is a genre: Array.sort() ordering numbers as strings is the same plot with a different built-in. A callback's contract bites harder still when forEach is handed an async function and never waits for it, so the loop finishes before a single promise settles. For a shared-reference trap on the array side, see Array(n).fill([]). Hub: bug write-ups.

Point-free style is elegant right up until the callback has opinions about its second argument.