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

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

  1. Step 1.

    Read the constraints off every field.

    node probe.mjs inventory
    
    qty      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 false

    el.step is the empty string when the attribute is absent, and the browser then uses 1.

  2. Step 2.

    Type nine values into the required field and print the validity set.

    node probe.mjs states
    
    typed "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. abc leaves value empty with valueMissing true, so the field reports a missing value for typed text. 12e also sets badInput.

  3. Step 3.

    Compare typing past max with stepping past it.

    node probe.mjs range
    
    typed "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 rangeOverflow true, while three ArrowUp presses on 10 leave 10.

  4. Step 4.

    Put decimals into three fields whose step differs.

    node probe.mjs step
    
    plain   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.5 a mismatch on a field that never mentioned step. On min="0.5" step="1" the count starts at the minimum: 3.5 passes, 3 fails.

  5. Step 5.

    Put values near 0.3 into the step="0.1" field.

    node probe.mjs float
    
    tenths  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 false

    0.35 is rejected and 0.30000000000000004 is not, though neither divides by 0.1 into an integer. The verdict carries a tolerance.

  6. Step 6.

    Send fourteen number shapes in twice, typed and assigned.

    node probe.mjs lexical
    
    free    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+0030

    Nine rows disagree. Keystrokes are filtered character by character and the survivors stay, so 3,5 becomes 35. An assignment empties anything that is not a valid floating-point number.

  7. Step 7.

    Type letters into the required field, turn validation off, and submit.

    node probe.mjs submit
    
    typed "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 text

    The field is invalid and the body still carries qty=, which no route can tell from an untouched field. Number of it is 0.

  8. 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 text

    1e3 reads 1000 through Number and 1 through parseInt. The empty value reads 0, and NaN is stored as text in an INTEGER column.

  9. 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 JSON

    A form-encoded body sends only strings. JSON sends 7 as a number, "7" as a string, null coerced to 0, and a bare NaN fails at the parser.

  10. Step 10.

    Read back every row the store kept.

    curl -s http://127.0.0.1:8932/rows
    
    id 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 null

    Three 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

Sign: A number field is signed off because letters cannot be typed into it.Cause: They can be typed, and they vanish. Step 2 typed abc and el.value was the empty string with valueMissing true, and step 6 typed 7abc into a field that then reported 7 as valid. The person sees the field go blank or shorten, the tester sees a field that behaves, and the body carries neither string.
Sign: min and max are exercised with the arrows, and the field is called capped.Cause: The arrows are the one path that respects them. Step 3 types 99 into a field whose max is 10 and the value stays 99 with rangeOverflow true, while three ArrowUp presses on 10 leave 10 and stepUp() on 99 does nothing. Type the out-of-range value, and assign it as well.
Sign: A price or weight field rejects a value the person is sure is correct.Cause: The default step is 1, so any decimal is a stepMismatch on a field that never wrote step. When min is present the count starts there: step 4 accepts 3.5 and rejects 3 on min=0.5 with step=1. Chrome names the two nearest valid values in the message, which is the fastest way to see where the count begins.
Sign: The route stores 0 for an order nobody placed, or 1 for one of a thousand.Cause: Number of the empty string is 0, so a sanitized field arrives as a zero quantity, which step 7 reproduces. parseInt of the string 1e3 is 1 while Number of it is 1000, and the field accepts 1e3 without complaint. Step 8 prints both readings of the same body.

What to check next

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.

intermediate12 minpublished updated Maks Verny