~/blog

Number('') is 0, so a blank field passes numeric validation

published

#javascript#node#validation

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:

InputNumber(input)parseFloat(input)
''0NaN
' '0NaN
'\n'0NaN
null0NaN
false0NaN
[]0NaN
undefinedNaNNaN
' 12 '1212
'12px'NaN12
'1,000'NaN1
'0x1A'260
'1e3'10001000

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

References