OneBug All write-ups

JavaScript ·

JavaScript 0 == Empty String Is True (and How to Fix It)

A quiz score of zero gets treated as a blank answer because 0 == empty string is true in JavaScript. Why the coercion happens, and the one-line === fix.

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

Spot the bug JavaScript
1
// Average the answered scores; blank cells import as "".
2
const scores = [4, "", 5, 0, 3];
3
let sum = 0;
4
let answered = 0;
5
for (const s of scores) {
6
  if (s == "") continue;
7
  sum += s;
8
  answered += 1;
9
}
10
const avg = Math.round((sum / answered) * 100) / 100;
11
console.log(avg);
12
// expected: 3
13
// actual:   4  (!)

Four scores - 4, 5, 0, and 3 - average to 3. The class dashboard prints 4. Nobody edited the spreadsheet between the export and the render, and the fifth cell, genuinely blank, was supposed to be the only thing the report skipped.

Here is the averaging loop, built to skip blank cells and count everything else:

// Average the answered scores; blank cells import as "".
const scores = [4, "", 5, 0, 3];
let sum = 0;
let answered = 0;
for (const s of scores) {
  if (s == "") continue;
  sum += s;
  answered += 1;
}
const avg = Math.round((sum / answered) * 100) / 100;
console.log(avg);
// expected: 3
// actual:   4  (!)

Why is 0 == "" true in JavaScript?

0 == "" evaluates to true, and so does s == "" for any variable s holding the number 0. Loose equality between a number and a string converts the string to a number before comparing, and Number("") is 0. Use s === "" to check for an empty string without that coercion.

What == does when one operand is a number and the other a string

Line 6 is where the loop decides what counts as blank: if (s == "") continue;. On the pass where s is the number 0, JavaScript does not compare a number to a string at all, not directly. == first checks whether the operand types already match; they do not, so it converts one side until they do. MDN's own equality reference names the exact rule for this pairing:

"Number to String: convert the string to a number. Conversion failure results in NaN, which will guarantee the equality to be false." - MDN: Equality (==)

The empty string converts cleanly. Number("") is 0, not NaN. An empty string is not a parse failure, it is the string form of nothing, and JavaScript reads nothing as zero:

console.log(Number(""));
// 0

So the comparison that actually runs on line 6 is 0 == 0, true by any definition. Line 10, the Math.round((sum / answered) * 100) / 100 chain, looks like the place a rounding bug would hide: two operations stacked on one line, the kind of arithmetic that earns a second look. It is doing its job correctly on numbers that were already wrong by the time they reached it.

The trap survives a code review because a fixture built from either all real numbers or all blanks never exercises both branches of the coercion. Test the loop with [4, "", 5, 3] and it passes: there is no zero to swallow. Test it with [4, 0, 5, 3] and it also passes: there is no blank string next to the zero to collide with. The bug needs a zero and an empty string in the same dataset, the shape a small hand-written test case tends to avoid, and the shape a real import full of optional fields eventually produces.

From a five-cell import to a grade that never adds up

A teacher's classroom app imports quiz scores from a spreadsheet export every Friday: one row per student, one column per question, blank cells for anything left unanswered. The averaging loop above runs on each student's row, skips the blanks, and writes the rest into a report-card entry. For most of the term this works fine, because most wrong answers score something other than zero: partial credit, a point for showing work, anything above nothing. Then a true-or-false question ships where a wrong answer scores exactly 0, and one student, having answered every question and missed that one, produces a row with a real zero sitting next to a genuinely blank extra-credit cell.

The import runs, the grade posts, and it lands one point higher than the student's own math says it should. The teacher does not doubt the averaging function, which has run correctly for a semester, so the first suspects are the export tool, then the gradebook's rounding, then a possible duplicate row. Only reading the raw CSV cell by cell surfaces it: the zero and the blank produce almost the same string on the way in, 0 sitting beside "", and only the loop's == "" check treats them as the same value. The fix is not in the export, the import, or the spreadsheet. It is one operator in a loop nobody had reopened since the day it was written.

The fix for 0 == "" (and the falsy check that fails the same way)

Swap == for ===, and the coercion never runs:

if (s === "") continue;

0 === "" compares a number and a string directly, with no conversion step, and returns false immediately because their types differ. The real zero survives, answered counts four scores instead of three, and the average comes back 3.

The tempting shortcut is if (!s) continue;, shorter, and it reads as "skip anything empty." It does not fix the bug; it recreates it under a different name. !s is true for "", for undefined, for NaN, and for the number 0, because all four belong to JavaScript's falsy set. The zero that == "" swallowed by accident, !s swallows on purpose. Anywhere a real zero, an empty array, or the string "0" might legitimately show up, a falsy check is the same bug wearing a shorter disguise.

How to find 0 == "" comparisons in your codebase

  • grep -rnE '(==|!=) *""' --include='*.js' --include='*.jsx' . catches the double-quoted form directly; run it again with ' if the codebase favors single quotes.
  • ESLint's core eqeqeq rule bans bare ==/!= everywhere, not only against empty strings, and flags a line like this with "Expected '===' and instead saw '=='." Turning it on kills the whole class in one pass, not just this one comparison.
  • Too big a change for a legacy file? Narrow the search to == "" and != "" specifically: both patterns are almost always a blank-check that should be === "", and both are one keystroke from the fix.

Related bugs

Loose equality's coercion table has more than one trap door. 0.1 + 0.2 refusing to equal 0.3 is the same family from the number side: a comparison that reads as true and is not, for reasons the type checker never flags. On the array side, map(parseInt) turning a clean index into a radix is another built-in doing exactly what it was told and nothing like what was meant. Browse the rest of the JavaScript write-ups, or the full bug archive.

The zero was always real. It just looked, for one operator, like nothing at all.