Number('') is 0, so a blank field passes numeric validation
published
TL;DR
Number(''), Number(' ') and +'' all return 0, not NaN. Any check of the form “convert, then reject NaN” therefore accepts a blank value as zero: a cleared form field saves a price of 0, Zod’s z.coerce.number() parses '' to 0, and a .env file with PORT= starts your server on a random port. Test for an empty, trimmed string first, then convert.
The problem
A quantity field, a price, a retry count, a port. The value arrives as a string, so the code converts it and validates the result:
function parseQty(raw) {
const n = Number(raw);
if (Number.isNaN(n)) throw new Error('not a number');
return n;
}
parseQty('3'); // 3
parseQty('abc'); // throws
parseQty(''); // 0
The last call is the bug. The user cleared the field, which should mean “missing” and fail validation, and instead the order goes through with quantity 0. Nothing throws, nothing logs.
Why it happens
The conversion is specified that way. ECMAScript’s string-to-number rules list the differences from numeric literals in source code, and one of them is that a string that is empty or contains only white space converts to +0. MDN’s Number coercion section says the same thing in plainer words. Unary + uses the same conversion, so +'' is 0 too.
Here is what Number() does with the inputs that tend to show up in real code, next to parseFloat for contrast. Output from Node 26.2.0:
| Input | Number(input) | parseFloat(input) |
|---|---|---|
'' | 0 | NaN |
' ' | 0 | NaN |
'\n' | 0 | NaN |
null | 0 | NaN |
false | 0 | NaN |
[] | 0 | NaN |
undefined | NaN | NaN |
' 12 ' | 12 | 12 |
'12px' | NaN | 12 |
'1,000' | NaN | 1 |
'0x1A' | 26 | 0 |
'1e3' | 1000 | 1000 |
So the two functions fail in opposite directions. Number is strict about trailing junk ('12px' is NaN) but lenient about emptiness. parseFloat rejects emptiness but silently stops at the first character it cannot read, which is the trap in parseFloat stops at the thousands separator. Neither one, on its own, means “this string is a number”.
The usual validity checks do not help, because by the time they run the empty string is already a perfectly good zero:
isNaN(''); // false
Number.isNaN(Number('')); // false
Number.isFinite(Number('')); // true
Where it bites
Schema coercion. Libraries that coerce with Number() inherit the rule. Zod documents that z.coerce.number() passes the input through Number(input), and with Zod 4.4.3:
import { z } from 'zod';
z.coerce.number().parse(''); // 0
z.coerce.number().parse(null); // 0
A form posted with an empty number field validates cleanly as zero, and so does a null from JSON.
Environment variables. An unset variable is undefined, and Number(undefined) is NaN, which most code catches. A variable that is set but empty is '', and that converts to 0. Node’s --env-file reads a line like PORT= as the empty string, and server.listen(0) asks the operating system for any free port:
// port.mjs
import http from 'node:http';
console.log('PORT is', JSON.stringify(process.env.PORT));
const port = Number(process.env.PORT);
console.log('Number(PORT) =', port);
const server = http.createServer().listen(port, () => {
console.log('listening on', server.address().port);
server.close();
});
With an env file containing just PORT=:
node --env-file=empty.env port.mjs
# PORT is ""
# Number(PORT) = 0
# listening on 52060
No error, and the service is up on a port nothing points at. The same thing happens with RETRIES= turning into zero retries, or TIMEOUT_MS= turning into a zero timeout. Environment values being strings has its own post, and --env-file parsing differs from dotenv in other ways covered in node —env-file is not dotenv.
Form fields in the browser. input.value on an empty type="number" field is '', so Number(input.value) is 0. The input’s valueAsNumber property is the better source here: MDN documents that it returns NaN when the value cannot be converted, which includes an empty field, so the usual Number.isNaN check works on it.
What to do
Decide what blank means before converting, and convert only a non-empty string:
function toNumberOrNull(raw) {
if (raw == null) return null;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
toNumberOrNull(''); // null
toNumberOrNull(' '); // null
toNumberOrNull('0'); // 0
toNumberOrNull(' 12 '); // 12
toNumberOrNull('12px'); // null
toNumberOrNull('1e3'); // 1000
toNumberOrNull(null); // null
Returning null keeps “missing” distinct from “zero”, so the caller can apply a default or reject the input on purpose. Number.isFinite also rejects Infinity, which Number('Infinity') happily produces.
For environment variables, fail fast on empty instead of defaulting silently:
function requiredInt(name) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') {
throw new Error(`${name} is not set`);
}
const n = Number(raw);
if (!Number.isInteger(n)) throw new Error(`${name} must be an integer, got ${JSON.stringify(raw)}`);
return n;
}
const port = requiredInt('PORT');
With Zod, reject the empty string before coercing rather than after. One way is to preprocess blank strings to undefined, so the field is reported as missing:
const qty = z.preprocess(
(v) => (typeof v === 'string' && v.trim() === '' ? undefined : v),
z.coerce.number().int().positive(),
);
qty.safeParse('').success; // false
qty.parse('3'); // 3
Caveats
toNumberOrNullstill accepts whatNumberaccepts once the string is non-empty, including hex ('0x1A'is26), exponents ('1e3') and'-0'. If a field should only take plain decimal digits, test it with a regular expression such as/^-?\d+(\.\d+)?$/before converting.null,falseand[]also convert to0. The helper above treatsnullas missing; if booleans or arrays can reach your converter, that is a separate bug worth catching earlier.- Locale formats such as
'1.234,5'are not numbers to eitherNumberorparseFloat. Parsing user-typed numbers in other locales needs its own handling. - Whether a blank field means zero is a product decision. Sometimes it should. The point is to make that choice in code, not inherit it from the conversion rules.