Input type number validation
Put values from the edges of the field into it and read el.value next to the whole validity set in Chrome. Typing abc here left el.value empty, so valueMissing fired on text the person typed, the form sent qty=, and the route read it as zero.
Why check this
Run this before release on any form that writes a number into a record, and again after a field gains min, max or step.
The sanitizing is the part nobody expects. A value Chrome cannot parse becomes the empty string, so a required field reports valueMissing on text the person typed. Step 2 measures it and step 7 submits it.
The failure this prevents is a quantity that changes magnitude on the way in. This machine runs a Ukrainian locale, where the decimal separator is a comma. Step 6 types 3,5 and the field holds 35.
step is the rule most testers have not met: it defaults to 1 and counts from min.
Prerequisites
- Node 22.
node:sqlitewarns unless you pass--no-warnings. - Chrome and
puppeteer-core(npm i puppeteer-core), withCHROMEpointed at your own binary. This run drove Chrome 152.0.7977.76, one capture on one machine. --lang=en-USis in the launch arguments:validationMessagefollows the browser UI locale, and this machine answers in Ukrainian.- curl, for steps 8 to 10, against
127.0.0.1. - Port 8932, confirmed free before you bind it, and stopped afterwards by its process id.
- The rule is in the HTML standard, under valid floating-point number.
Save the form as form.html.
<!doctype html>
<meta charset="utf-8">
<title>number fixture</title>
<form id="f" method="post" action="/save">
<label>Quantity <input id="qty" name="qty" type="number" min="1" max="10" required></label>
<label>Plain <input id="plain" name="plain" type="number"></label>
<label>Offset <input id="offset" name="offset" type="number" min="0.5" step="1"></label>
<label>Tenths <input id="tenths" name="tenths" type="number" min="0" step="0.1"></label>
<label>Free <input id="free" name="free" type="number" step="any"></label>
<button id="go" type="submit">Save</button>
</form>
Save the endpoint as target.mjs.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
const PORT = 8932;
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, qty INTEGER)');
const insert = db.prepare('INSERT INTO orders (qty) VALUES (?)');
const readBack = db.prepare('SELECT id, qty, typeof(qty) AS t FROM orders WHERE id = ?');
const all = db.prepare('SELECT id, qty, typeof(qty) AS t FROM orders ORDER BY id');
createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(readFileSync(new URL('form.html', import.meta.url)));
}
if (url.pathname === '/favicon.ico') {
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && url.pathname === '/rows') {
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(all.all().map((r) => 'id ' + r.id + ' qty ' + JSON.stringify(r.qty)
+ ' sqlite typeof ' + r.t).join('\n') + '\n');
}
if (!(req.method === 'POST' && url.pathname === '/save')) {
res.writeHead(404, { 'content-type': 'text/plain' });
return res.end('not found\n');
}
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const ct = (req.headers['content-type'] || '(absent)').split(';')[0];
const out = ['content-type ' + ct, 'raw body [' + raw + ']'];
let qty;
if (ct === 'application/json') {
try {
qty = JSON.parse(raw).qty;
} catch (e) {
out.push('JSON.parse ' + e.name + ': ' + e.message.split('\n')[0]);
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(out.join('\n') + '\n');
}
} else {
const f = new URLSearchParams(raw);
qty = f.has('qty') ? f.get('qty') : undefined;
}
out.push('qty ' + (qty === undefined ? '(absent)' : JSON.stringify(qty))
+ ' typeof ' + typeof qty);
const n = Number(qty);
out.push('parseInt ' + String(parseInt(qty, 10)));
out.push('Number(qty) ' + String(n) + ' isInteger ' + Number.isInteger(n)
+ ' isFinite ' + Number.isFinite(n));
const row = insert.run(qty === undefined ? null : qty);
const back = readBack.get(row.lastInsertRowid);
out.push('stored id ' + back.id + ' qty ' + JSON.stringify(back.qty)
+ ' sqlite typeof ' + back.t);
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
res.end(out.join('\n') + '\n');
});
}).listen(PORT, '127.0.0.1', () => console.log('number fixture on http://127.0.0.1:' + PORT));
Save the driver as probe.mjs, one subcommand per step.
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8932';
const mode = process.argv[2] || 'inventory';
const pad = (s, w) => String(s).padEnd(w);
const esc = (s) => [...s].map((c) => (c.codePointAt(0) < 128 ? c
: '<U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '>')).join('');
const cps = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
const q = (s) => '"' + esc(s) + '"';
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
const assign = (id, v) => page.evaluate((i, t) => { document.getElementById(i).value = t; }, id, v);
const type = async (id, v) => {
await page.evaluate((i) => { const e = document.getElementById(i); e.value = ''; e.focus(); }, id);
await page.type('#' + id, v);
};
const state = (id) => page.evaluate((i) => {
const el = document.getElementById(i);
const v = el.validity;
return { value: el.value, num: String(el.valueAsNumber), badInput: v.badInput,
missing: v.valueMissing, under: v.rangeUnderflow, over: v.rangeOverflow,
step: v.stepMismatch, valid: v.valid, message: el.validationMessage };
}, id);
const attrs = (id) => page.evaluate((i) => {
const el = document.getElementById(i);
return 'min ' + (el.min === '' ? '(absent)' : el.min) + ' step '
+ (el.step === '' ? '(absent, default 1)' : el.step);
}, id);
if (mode === 'inventory') {
const rows = await page.evaluate(() => [...document.querySelectorAll('input')].map((el) => ({
id: el.id, type: el.type, min: el.min, max: el.max, step: el.step, req: el.required })));
for (const r of rows) {
console.log(pad(r.id, 9) + 'type ' + pad(r.type, 8) + 'min ' + pad(r.min === '' ? '(absent)' : r.min, 10)
+ 'max ' + pad(r.max === '' ? '(absent)' : r.max, 10)
+ 'step ' + pad(r.step === '' ? '(absent, default 1)' : r.step, 20) + 'required ' + r.req);
}
} else if (mode === 'states') {
for (const v of ['7', 'abc', '12e', '7abc', '', '12', '0', '3.5', '-4']) {
await type('qty', v);
const s = await state('qty');
console.log('typed ' + pad(q(v), 8) + 'value ' + pad(q(s.value), 7) + 'valueAsNumber ' + pad(s.num, 5)
+ 'badInput ' + pad(s.badInput, 7) + 'valueMissing ' + pad(s.missing, 7)
+ 'rangeUnderflow ' + pad(s.under, 7) + 'rangeOverflow ' + pad(s.over, 7)
+ 'stepMismatch ' + pad(s.step, 7) + 'valid ' + s.valid);
}
for (const v of ['abc', '12e']) {
await type('qty', v);
console.log('message after typing ' + pad(q(v), 8) + JSON.stringify((await state('qty')).message));
}
} else if (mode === 'range') {
await type('qty', '99');
let s = await state('qty');
console.log('typed "99" value ' + pad(q(s.value), 6) + 'rangeOverflow ' + pad(s.over, 7)
+ 'message ' + JSON.stringify(s.message));
await page.evaluate(() => { const e = document.getElementById('qty'); e.value = '10'; e.focus(); });
for (let i = 0; i < 3; i += 1) await page.keyboard.press('ArrowUp');
s = await state('qty');
console.log('10, three ArrowUp value ' + pad(q(s.value), 6) + 'rangeOverflow ' + pad(s.over, 7) + 'valid ' + s.valid);
await page.evaluate(() => { const e = document.getElementById('qty'); e.value = '1'; e.focus(); });
for (let i = 0; i < 3; i += 1) await page.keyboard.press('ArrowDown');
s = await state('qty');
console.log('1, three ArrowDown value ' + pad(q(s.value), 6) + 'rangeUnderflow ' + pad(s.under, 7) + 'valid ' + s.valid);
await assign('qty', '99');
console.log('value 99, then ' + await page.evaluate(() => {
const el = document.getElementById('qty');
try { el.stepUp(); return 'stepUp() left value ' + JSON.stringify(el.value); }
catch (e) { return 'stepUp() threw ' + e.name; }
}));
} else if (mode === 'step') {
for (const [id, vals] of [
['plain', ['3', '3.5', '-2.5']],
['offset', ['3.5', '3', '0.5', '2']],
['free', ['3.5', '0.123456789', '1e-7']],
]) {
console.log(pad(id, 8) + await attrs(id));
for (const v of vals) {
await assign(id, v);
const s = await state(id);
console.log(' ' + pad(q(v), 14) + 'valueAsNumber ' + pad(s.num, 13) + 'stepMismatch ' + pad(s.step, 7)
+ 'valid ' + pad(s.valid, 7) + 'message ' + JSON.stringify(s.message));
}
}
} else if (mode === 'float') {
console.log('tenths ' + await attrs('tenths'));
for (const v of ['0.1', '0.3', '0.35', '0.30000000000000004', '0.09999999999999999', '1e21']) {
await assign('tenths', v);
const s = await state('tenths');
const n = Number(v);
console.log(' ' + pad(q(v), 23) + 'valueAsNumber ' + pad(s.num, 23)
+ 'n / 0.1 is an integer ' + pad(Number.isInteger(n / 0.1), 7)
+ 'stepMismatch ' + s.step);
}
} else if (mode === 'lexical') {
console.log('free ' + await attrs('free'));
console.log(pad('input', 12) + pad('typed value', 14) + pad('asNumber', 10)
+ pad('assigned value', 16) + pad('asNumber', 10) + 'code points of the input');
for (const v of ['+5', '1e3', '5e-3', ' 5', '5 ', '3,5', '3.', '.5', '\u0665', '\uFF15',
'Infinity', 'NaN', '0x10', '1_000']) {
await type('free', v);
const t = await state('free');
await assign('free', v);
const a = await state('free');
console.log(pad(q(v), 12) + pad(q(t.value), 14) + pad(t.num, 10)
+ pad(q(a.value), 16) + pad(a.num, 10) + cps(v));
}
} else if (mode === 'submit') {
await type('qty', 'abc');
console.log('typed "abc" into qty, el.value is ' + q((await state('qty')).value));
console.log('form.checkValidity() ' + await page.evaluate(() => document.getElementById('f').checkValidity()));
await page.evaluate(() => { document.getElementById('f').noValidate = true; });
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#go')]);
console.log('landed on ' + new URL(page.url()).pathname);
console.log(await page.evaluate(() => document.body.innerText.trim()));
} else if (mode === 'version') {
console.log(await browser.version());
}
await browser.close();
Start the endpoint.
node --no-warnings target.mjs
Steps
- Step 1.
Read the constraints off every field.
node probe.mjs inventoryqty type number min 1 max 10 step (absent, default 1) required true plain type number min (absent) max (absent) step (absent, default 1) required false offset type number min 0.5 max (absent) step 1 required false tenths type number min 0 max (absent) step 0.1 required false free type number min (absent) max (absent) step any required falseel.stepis the empty string when the attribute is absent, and the browser then uses 1. - Step 2.
Type nine values into the required field and print the validity set.
node probe.mjs statestyped "7" value "7" valueAsNumber 7 badInput false valueMissing false rangeUnderflow false rangeOverflow false stepMismatch false valid true typed "abc" value "" valueAsNumber NaN badInput false valueMissing true rangeUnderflow false rangeOverflow false stepMismatch false valid false typed "12e" value "" valueAsNumber NaN badInput true valueMissing true rangeUnderflow false rangeOverflow false stepMismatch false valid false typed "7abc" value "7" valueAsNumber 7 badInput false valueMissing false rangeUnderflow false rangeOverflow false stepMismatch false valid true typed "" value "" valueAsNumber NaN badInput false valueMissing true rangeUnderflow false rangeOverflow false stepMismatch false valid false typed "12" value "12" valueAsNumber 12 badInput false valueMissing false rangeUnderflow false rangeOverflow true stepMismatch false valid false typed "0" value "0" valueAsNumber 0 badInput false valueMissing false rangeUnderflow true rangeOverflow false stepMismatch false valid false typed "3.5" value "3.5" valueAsNumber 3.5 badInput false valueMissing false rangeUnderflow false rangeOverflow false stepMismatch true valid false typed "-4" value "-4" valueAsNumber -4 badInput false valueMissing false rangeUnderflow true rangeOverflow false stepMismatch false valid false message after typing "abc" "Please fill out this field." message after typing "12e" "Please enter a number."Row two is the point of the page.
abcleavesvalueempty withvalueMissingtrue, so the field reports a missing value for typed text.12ealso setsbadInput. - Step 3.
Compare typing past
maxwith stepping past it.node probe.mjs rangetyped "99" value "99" rangeOverflow true message "Value must be less than or equal to 10." 10, three ArrowUp value "10" rangeOverflow false valid true 1, three ArrowDown value "1" rangeUnderflow false valid true value 99, then stepUp() left value "99"Typing passes the limit and the arrows do not. The field keeps 99 with
rangeOverflowtrue, while three ArrowUp presses on 10 leave 10. - Step 4.
Put decimals into three fields whose
stepdiffers.node probe.mjs stepplain min (absent) step (absent, default 1) "3" valueAsNumber 3 stepMismatch false valid true message "" "3.5" valueAsNumber 3.5 stepMismatch true valid false message "Please enter a valid value. The two nearest valid values are 3 and 4." "-2.5" valueAsNumber -2.5 stepMismatch true valid false message "Please enter a valid value. The two nearest valid values are -3 and -2." offset min 0.5 step 1 "3.5" valueAsNumber 3.5 stepMismatch false valid true message "" "3" valueAsNumber 3 stepMismatch true valid false message "Please enter a valid value. The two nearest valid values are 2.5 and 3.5." "0.5" valueAsNumber 0.5 stepMismatch false valid true message "" "2" valueAsNumber 2 stepMismatch true valid false message "Please enter a valid value. The two nearest valid values are 1.5 and 2.5." free min (absent) step any "3.5" valueAsNumber 3.5 stepMismatch false valid true message "" "0.123456789" valueAsNumber 0.123456789 stepMismatch false valid true message "" "1e-7" valueAsNumber 1e-7 stepMismatch false valid true message ""A default step of 1 makes
3.5a mismatch on a field that never mentionedstep. Onmin="0.5" step="1"the count starts at the minimum: 3.5 passes, 3 fails. - Step 5.
Put values near 0.3 into the
step="0.1"field.node probe.mjs floattenths min 0 step 0.1 "0.1" valueAsNumber 0.1 n / 0.1 is an integer true stepMismatch false "0.3" valueAsNumber 0.3 n / 0.1 is an integer false stepMismatch false "0.35" valueAsNumber 0.35 n / 0.1 is an integer false stepMismatch true "0.30000000000000004" valueAsNumber 0.30000000000000004 n / 0.1 is an integer false stepMismatch false "0.09999999999999999" valueAsNumber 0.09999999999999999 n / 0.1 is an integer false stepMismatch false "1e21" valueAsNumber 1e+21 n / 0.1 is an integer true stepMismatch false0.35is rejected and0.30000000000000004is not, though neither divides by 0.1 into an integer. The verdict carries a tolerance. - Step 6.
Send fourteen number shapes in twice, typed and assigned.
node probe.mjs lexicalfree min (absent) step any input typed value asNumber assigned value asNumber code points of the input "+5" "5" 5 "" NaN U+002B U+0035 "1e3" "1e3" 1000 "1e3" 1000 U+0031 U+0065 U+0033 "5e-3" "5e-3" 0.005 "5e-3" 0.005 U+0035 U+0065 U+002D U+0033 " 5" "5" 5 "" NaN U+0020 U+0035 "5 " "5" 5 "" NaN U+0035 U+0020 "3,5" "35" 35 "" NaN U+0033 U+002C U+0035 "3." "3" 3 "" NaN U+0033 U+002E ".5" ".5" 0.5 ".5" 0.5 U+002E U+0035 "<U+0665>" "" NaN "" NaN U+0665 "<U+FF15>" "5" 5 "" NaN U+FF15 "Infinity" "" NaN "" NaN U+0049 U+006E U+0066 U+0069 U+006E U+0069 U+0074 U+0079 "NaN" "" NaN "" NaN U+004E U+0061 U+004E "0x10" "010" 10 "" NaN U+0030 U+0078 U+0031 U+0030 "1_000" "1000" 1000 "" NaN U+0031 U+005F U+0030 U+0030 U+0030Nine rows disagree. Keystrokes are filtered character by character and the survivors stay, so
3,5becomes 35. An assignment empties anything that is not a valid floating-point number. - Step 7.
Type letters into the required field, turn validation off, and submit.
node probe.mjs submittyped "abc" into qty, el.value is "" form.checkValidity() false landed on /save content-type application/x-www-form-urlencoded raw body [qty=&plain=&offset=&tenths=&free=] qty "" typeof string parseInt NaN Number(qty) 0 isInteger true isFinite true stored id 1 qty "" sqlite typeof textThe field is invalid and the body still carries
qty=, which no route can tell from an untouched field.Numberof it is 0. - Step 8.
Post five form-encoded bodies, no browser in the path.
for v in 1e3 "" 011 " 8" NaN; do echo "== qty=[$v]"; curl -s -X POST http://127.0.0.1:8932/save --data-urlencode "qty=$v"; done== qty=[1e3] content-type application/x-www-form-urlencoded raw body [qty=1e3] qty "1e3" typeof string parseInt 1 Number(qty) 1000 isInteger true isFinite true stored id 2 qty 1000 sqlite typeof integer == qty=[] content-type application/x-www-form-urlencoded raw body [qty=] qty "" typeof string parseInt NaN Number(qty) 0 isInteger true isFinite true stored id 3 qty "" sqlite typeof text == qty=[011] content-type application/x-www-form-urlencoded raw body [qty=011] qty "011" typeof string parseInt 11 Number(qty) 11 isInteger true isFinite true stored id 4 qty 11 sqlite typeof integer == qty=[ 8] content-type application/x-www-form-urlencoded raw body [qty=+8] qty " 8" typeof string parseInt 8 Number(qty) 8 isInteger true isFinite true stored id 5 qty 8 sqlite typeof integer == qty=[NaN] content-type application/x-www-form-urlencoded raw body [qty=NaN] qty "NaN" typeof string parseInt NaN Number(qty) NaN isInteger false isFinite false stored id 6 qty "NaN" sqlite typeof text1e3reads 1000 throughNumberand 1 throughparseInt. The empty value reads 0, andNaNis stored as text in anINTEGERcolumn. - Step 9.
Send the same field as JSON, which can carry a type.
for j in '{"qty":7}' '{"qty":"7"}' '{"qty":"1e3"}' '{"qty":null}' '{"qty":NaN}'; do echo "== body $j"; curl -s -X POST http://127.0.0.1:8932/save -H 'content-type: application/json' -d "$j"; done== body {"qty":7} content-type application/json raw body [{"qty":7}] qty 7 typeof number parseInt 7 Number(qty) 7 isInteger true isFinite true stored id 7 qty 7 sqlite typeof integer == body {"qty":"7"} content-type application/json raw body [{"qty":"7"}] qty "7" typeof string parseInt 7 Number(qty) 7 isInteger true isFinite true stored id 8 qty 7 sqlite typeof integer == body {"qty":"1e3"} content-type application/json raw body [{"qty":"1e3"}] qty "1e3" typeof string parseInt 1 Number(qty) 1000 isInteger true isFinite true stored id 9 qty 1000 sqlite typeof integer == body {"qty":null} content-type application/json raw body [{"qty":null}] qty null typeof object parseInt NaN Number(qty) 0 isInteger true isFinite true stored id 10 qty null sqlite typeof null == body {"qty":NaN} content-type application/json raw body [{"qty":NaN}] JSON.parse SyntaxError: Unexpected token 'N', "{"qty":NaN}" is not valid JSONA form-encoded body sends only strings. JSON sends
7as a number,"7"as a string,nullcoerced to 0, and a bareNaNfails at the parser. - Step 10.
Read back every row the store kept.
curl -s http://127.0.0.1:8932/rowsid 1 qty "" sqlite typeof text id 2 qty 1000 sqlite typeof integer id 3 qty "" sqlite typeof text id 4 qty 11 sqlite typeof integer id 5 qty 8 sqlite typeof integer id 6 qty "NaN" sqlite typeof text id 7 qty 7 sqlite typeof integer id 8 qty 7 sqlite typeof integer id 9 qty 1000 sqlite typeof integer id 10 qty null sqlite typeof nullThree of the ten rows hold text and one holds null, in a column declared
INTEGER.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| value empty and valueMissing true after typing | The entry did not parse, so the browser replaced it with the empty string | Read badInput beside it. A missing-value message for typed text is a second defect. |
| badInput true and value empty | Some characters were kept and the result is still not a number | Assert on badInput, not on emptiness. Step 2 has 12e in that state. |
| value shorter than what was typed | The keystroke filter dropped characters and kept the rest | Compare the typed string with el.value, as step 6 does. A silent drop changes the magnitude. |
| rangeOverflow true on a field with max | The value was typed or assigned, not stepped | The attribute does not cap typing. Confirm the route rejects the same number. |
| stepMismatch true on a whole number | step is counted from min, not from zero | Read min and step together. Step 4 rejects 3 on min="0.5" step="1". |
| stepMismatch false on a decimal that is not a multiple | The verdict carries a floating-point tolerance | Do not derive the rule from one accepted value. Test the value the product cares about. |
| The body carries qty= after a typed entry | The field sanitized before the submit | Make the route separate an absent key, an empty value and a bad value. |
| sqlite typeof reading text in an INTEGER column | The store took the string as it came | SQLite applies affinity, not a type check. Validate before the insert. |
Common mistakes
What to check next
- How to check required field validation: fires here on typed text.
- How to test that client validation is enforced on the server: the case behind steps 8 and 9.
- Html input pattern validation: the other markup rule.
- How to check field length limits in a form: the same three layers, on text.
- How to check date input parsing in a form: the same questions, on dates.
FAQ
How do I check if input is a number?
Read el.value and el.validity.badInput together. An entry that does not parse leaves value empty, so emptiness alone cannot separate a blank field from a wrong one.
How does input type number min max validation work?
They set rangeUnderflow and rangeOverflow and block the submit, and they do not stop typing. Step 3 keeps 99 in a field whose max is 10.
Why does a number field report a missing value when I typed something?
Chrome replaces a value it cannot parse with the empty string, and required then reports valueMissing. Step 2 typed abc and got Please fill out this field.
What does the step attribute do on a number input?
It fixes the allowed increments: the default is 1, counted from min when there is one. step="any" turns the check off.
Does type="number" stop bad numbers reaching the server?
No. Step 8 posts 1e3, an empty value and NaN with curl, and the store keeps all three.
Verified
Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2curl 8.21.0Windows 11 build 22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny