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.
// 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 (!)
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
eqeqeqrule 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.