How to check date input parsing in a form
Fill the field, then read el.value, el.valueAsNumber and el.valueAsDate off it. Chrome yields 2026-03-08 whatever the segments on screen say, and valueAsDate is a Date at UTC midnight. Parse that one string two ways in Node and the calendar day moves by one.
Why check this
Run this at sign-off on any form that writes a date, and after the field, the route or the column changes. A date field is three programs: a control that renders one way and yields another, a parser, and a column that keeps a day or a moment.
The defect arrives as "it sent the wrong date", and it is rarely the field or the server. It is the hop between them, where a calendar day becomes an instant and is read back elsewhere.
Rendering belongs to How to test date format in different locales.
Prerequisites
- Node 22.23.2, ICU 78.2,
node:sqlite3.51.3. - Chrome 152.0.7977.76 driven by
puppeteer-core25.10.0, one capture on one machine. - This machine's zone is
Europe/Kiev, its localeuk-UA. Read your own first. - The probe passes
--lang=en-US, without whichvalidationMessageanswers in Ukrainian here. - PowerShell, for steps 7 and 10.
- The date state of the input element fixes the format.
Save the form as form.html.
<!doctype html>
<meta charset="utf-8">
<title>date fixture</title>
<form id="f" method="post" action="/save">
<label>Due <input id="due" name="due" type="date" required></label>
<label>Window <input id="win" name="win" type="date" min="2026-03-02" max="2026-03-31" step="7"></label>
<button id="go" type="submit">Save</button>
</form>
Save the endpoint as target.mjs. It keeps the string received, a DATE column and an epoch column.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
const PORT = 8935;
const db = new DatabaseSync('dates.db');
db.exec('CREATE TABLE IF NOT EXISTS bookings (id INTEGER PRIMARY KEY, sent TEXT, day DATE, moment INTEGER)');
const ins = db.prepare('INSERT INTO bookings (sent, day, moment) VALUES (?, ?, ?)');
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 === 'POST' && url.pathname === '/save')) {
res.writeHead(404, { 'content-type': 'text/plain' });
return res.end('not found');
}
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
const sent = body.get('due') ?? '';
const parsed = new Date(sent);
const ok = !Number.isNaN(parsed.getTime());
if (ok) ins.run(sent, sent, parsed.getTime());
res.writeHead(ok ? 201 : 400, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ sent, parsedISO: ok ? parsed.toISOString() : null, stored: ok }) + '\n');
});
}).listen(PORT, '127.0.0.1', () => console.log('date fixture on http://127.0.0.1:' + PORT));
Save the driver as probe.mjs. Each subcommand is a step except version.
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8935';
const mode = process.argv[2];
const pad = (s, w) => String(s).padEnd(w);
async function open(lang) {
const profile = mkdtempSync(join(tmpdir(), 'h2c-date-'));
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile, args: ['--lang=' + lang] });
const page = (await browser.pages())[0];
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
const set = (id, v) => page.evaluate((i, t) => { document.getElementById(i).value = t; }, id, v);
const state = (id) => page.evaluate((i) => {
const el = document.getElementById(i);
const d = el.valueAsDate;
return {
value: el.value,
// String() runs inside the page: NaN crosses the CDP boundary as null.
valueAsNumber: String(el.valueAsNumber),
valueAsDate: d === null ? 'null' : d.toISOString(),
ctor: d === null ? 'null' : d.constructor.name,
badInput: el.validity.badInput,
valueMissing: el.validity.valueMissing,
rangeUnderflow: el.validity.rangeUnderflow,
rangeOverflow: el.validity.rangeOverflow,
stepMismatch: el.validity.stepMismatch,
valid: el.validity.valid,
message: el.validationMessage,
};
}, id);
const close = async () => { await browser.close(); rmSync(profile, { recursive: true, force: true }); };
return { browser, page, set, state, close };
}
if (mode === 'yield') {
const b = await open('en-US');
await b.set('due', '2026-03-08');
const s = await b.state('due');
console.log('the picker was given 8 March 2026');
console.log(' el.value ' + JSON.stringify(s.value));
console.log(' el.valueAsNumber ' + s.valueAsNumber);
console.log(' el.valueAsDate ' + s.valueAsDate + ' (' + s.ctor + ')');
await b.close();
} else if (mode === 'shown') {
for (const lang of ['en-US', 'uk']) {
const b = await open(lang);
await b.set('due', '2026-03-08');
const snap = await b.page.accessibility.snapshot();
const node = (function find(n) {
if (n.role === 'Date') return n;
for (const c of n.children || []) { const r = find(c); if (r) return r; }
return null;
})(snap);
const parts = [];
(function walk(n) {
if (n.role === 'spinbutton') parts.push(n.name.split(' ')[0] + '=' + n.value);
else if (n.role === 'StaticText') parts.push(n.name);
(n.children || []).forEach(walk);
})(node);
console.log(pad('--lang=' + lang, 14) + pad('el.value ' + JSON.stringify(node.value), 24) + 'segments as rendered ' + parts.join(' '));
await b.close();
}
} else if (mode === 'bad') {
const b = await open('en-US');
console.log(pad('assigned', 16) + pad('el.value', 14) + pad('valueAsNumber', 15) + pad('valueAsDate', 26) + pad('badInput', 10) + pad('valueMissing', 14) + 'valid');
for (const v of [null, 'not a date', '2026-02-30', '2026-13-01', '2026-2-8', '08/03/2026', '2026-03-08']) {
await b.set('due', v === null ? '' : v);
const s = await b.state('due');
console.log(
pad(v === null ? 'never touched' : JSON.stringify(v), 16) +
pad(JSON.stringify(s.value), 14) +
pad(s.valueAsNumber, 15) +
pad(s.valueAsDate, 26) +
pad(s.badInput, 10) +
pad(s.valueMissing, 14) +
s.valid
);
}
await b.close();
} else if (mode === 'step') {
const b = await open('en-US');
const a = await b.page.evaluate(() => {
const el = document.getElementById('win');
return { min: el.min, max: el.max, step: el.step };
});
console.log('min ' + a.min + ' max ' + a.max + ' step ' + a.step);
console.log(pad('value', 14) + pad('rangeUnderflow', 16) + pad('rangeOverflow', 15) + pad('stepMismatch', 14) + pad('valid', 7) + 'validationMessage');
for (const v of ['2026-03-02', '2026-03-03', '2026-03-09', '2026-03-10', '2026-03-30', '2026-03-01', '2026-04-06']) {
await b.set('win', v);
const s = await b.state('win');
console.log(
pad(v, 14) + pad(s.rangeUnderflow, 16) + pad(s.rangeOverflow, 15) +
pad(s.stepMismatch, 14) + pad(s.valid, 7) + JSON.stringify(s.message)
);
}
await b.close();
} else if (mode === 'submit') {
const b = await open('en-US');
await b.set('due', '2026-03-08');
await b.set('win', '2026-03-09');
console.log('form.checkValidity() ' + await b.page.evaluate(() => document.getElementById('f').checkValidity()));
await Promise.all([b.page.waitForNavigation({ waitUntil: 'networkidle2' }), b.page.click('#go')]);
console.log('landed on ' + new URL(b.page.url()).pathname);
console.log(await b.page.evaluate(() => document.body.textContent.trim()));
await b.close();
} else if (mode === 'version') {
const b = await open('en-US');
console.log(await b.browser.version());
await b.close();
}
Save parse.mjs.
const sent = process.argv[2] || '2026-03-08';
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const day = (d) => new Intl.DateTimeFormat('en-CA', { dateStyle: 'short', timeZone: zone }).format(d);
const clock = (d) => new Intl.DateTimeFormat('en-GB', { timeStyle: 'medium', timeZone: zone }).format(d);
const pad = (s, w) => String(s).padEnd(w);
console.log('process zone ' + zone + ' value from the field ' + JSON.stringify(sent));
console.log(pad('expression', 40) + pad('toISOString()', 26) + pad('local day', 12) + 'local clock');
const rows = [
["new Date('" + sent + "')", new Date(sent)],
["new Date('" + sent + "T00:00:00')", new Date(sent + 'T00:00:00')],
["new Date(Date.UTC(y, m - 1, d))", new Date(Date.UTC(...sent.split('-').map(Number).map((n, i) => (i === 1 ? n - 1 : n))))],
["new Date(y, m - 1, d)", new Date(...sent.split('-').map(Number).map((n, i) => (i === 1 ? n - 1 : n)))],
];
for (const [label, d] of rows) {
console.log(pad(label, 40) + pad(d.toISOString(), 26) + pad(day(d), 12) + clock(d));
}
Save read.mjs.
import { DatabaseSync } from 'node:sqlite';
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const db = new DatabaseSync('dates.db');
const rows = db.prepare('SELECT id, sent, day, moment FROM bookings ORDER BY id').all();
const pad = (s, w) => String(s).padEnd(w);
const localDay = (ms) => new Intl.DateTimeFormat('en-CA', { dateStyle: 'short', timeZone: zone }).format(new Date(ms));
console.log('reader zone ' + zone);
console.log(pad('id', 4) + pad('sent', 14) + pad('day column (DATE)', 20) + pad('moment column (INTEGER)', 25) + 'moment as a day here');
for (const r of rows) {
console.log(
pad(r.id, 4) +
pad(JSON.stringify(r.sent), 14) +
pad(JSON.stringify(r.day) + ' ' + typeof r.day, 20) +
pad(r.moment, 25) +
localDay(r.moment)
);
}
Start the endpoint.
node target.mjs
Steps
- Step 1.
Put one date in the field and read the three properties that carry it.
node probe.mjs yieldthe picker was given 8 March 2026 el.value "2026-03-08" el.valueAsNumber 1772928000000 el.valueAsDate 2026-03-08T00:00:00.000Z (Date)One day, three types.
valueAsDateis a moment at UTC midnight, which seeds every off-by-one-day report below. - Step 2.
Read the same filled field under two interface languages.
node probe.mjs shown--lang=en-US el.value "2026-03-08" segments as rendered Month=3 / Day=8 / Year=2026 --lang=uk el.value "2026-03-08" segments as rendered День=8 . Місяць=3 . Рік=2026Segment order and separator follow the interface language;
el.valuedoes not move. What a person reads off the screen is not what the form sends. - Step 3.
Assign five strings the control cannot hold, and one it can.
node probe.mjs badassigned el.value valueAsNumber valueAsDate badInput valueMissing valid never touched "" NaN null false true false "not a date" "" NaN null false true false "2026-02-30" "" NaN null false true false "2026-13-01" "" NaN null false true false "2026-2-8" "" NaN null false true false "08/03/2026" "" NaN null false true false "2026-03-08" "2026-03-08" 1772928000000 2026-03-08T00:00:00.000Z false false trueSix assignments produce one state. The control stores nothing it cannot parse, so it reports itself empty rather than wrong and
badInputstays false. - Step 4.
Measure
min,maxandstepon the second field.node probe.mjs stepmin 2026-03-02 max 2026-03-31 step 7 value rangeUnderflow rangeOverflow stepMismatch valid validationMessage 2026-03-02 false false false true "" 2026-03-03 false false true false "Please enter a valid value. The two nearest valid values are 03/02/2026 and 03/09/2026." 2026-03-09 false false false true "" 2026-03-10 false false true false "Please enter a valid value. The two nearest valid values are 03/09/2026 and 03/16/2026." 2026-03-30 false false false true "" 2026-03-01 true false true false "Value must be 03/02/2026 or later." 2026-04-06 false true false false "Value must be 03/31/2026 or earlier."stepcounts days frommin, so six days in seven setstepMismatchand the field still holds the value.2026-03-01sets two flags while the message names one. - Step 5.
Parse the yielded string four ways in the machine zone.
node parse.mjs 2026-03-08process zone Europe/Kiev value from the field "2026-03-08" expression toISOString() local day local clock new Date('2026-03-08') 2026-03-08T00:00:00.000Z 2026-03-08 02:00:00 new Date('2026-03-08T00:00:00') 2026-03-07T22:00:00.000Z 2026-03-08 00:00:00 new Date(Date.UTC(y, m - 1, d)) 2026-03-08T00:00:00.000Z 2026-03-08 02:00:00 new Date(y, m - 1, d) 2026-03-07T22:00:00.000Z 2026-03-08 00:00:00A bare date string is parsed as UTC, the same date with a time as local. The two forms are one calendar day apart in the ISO column.
- Step 6.
Try to move the process into another zone from Git Bash.
TZ=America/New_York node -p "process.env.TZ + ' | ' + Intl.DateTimeFormat().resolvedOptions().timeZone"undefined | Europe/KievThe variable never reached the child and nothing reported an error. A test written this way measures the machine zone and passes for the wrong reason.
- Step 7.
Set the zone from PowerShell and parse again.
$env:TZ = 'America/New_York'; node parse.mjs 2026-03-08process zone America/New_York value from the field "2026-03-08" expression toISOString() local day local clock new Date('2026-03-08') 2026-03-08T00:00:00.000Z 2026-03-07 19:00:00 new Date('2026-03-08T00:00:00') 2026-03-08T05:00:00.000Z 2026-03-08 00:00:00 new Date(Date.UTC(y, m - 1, d)) 2026-03-08T00:00:00.000Z 2026-03-07 19:00:00 new Date(y, m - 1, d) 2026-03-08T05:00:00.000Z 2026-03-08 00:00:00The same four expressions, the other zone, and the error changes ends. Behind UTC the bare parse renders
2026-03-07; ahead of UTC the local parse serialises it. - Step 8.
Submit the form and read what the route parsed.
node probe.mjs submitform.checkValidity() true landed on /save {"sent":"2026-03-08","parsedISO":"2026-03-08T00:00:00.000Z","stored":true}The wire carries
2026-03-08, not the segments on screen. The control and the parser agree here. - Step 9.
Replay four refused strings with no browser in the path.
for d in "" "08/03/2026" "2026-02-30" "2026-13-01"; do curl -s -X POST http://127.0.0.1:8935/save --data-urlencode "due=$d" -w ' HTTP %{http_code}\n'; done{"sent":"","parsedISO":null,"stored":false} HTTP 400 {"sent":"08/03/2026","parsedISO":"2026-08-02T21:00:00.000Z","stored":true} HTTP 201 {"sent":"2026-02-30","parsedISO":"2026-03-02T00:00:00.000Z","stored":true} HTTP 201 {"sent":"2026-13-01","parsedISO":null,"stored":false} HTTP 400Two of four are stored.
08/03/2026, the string step 2 displayed under en-US, is read as 3 August.2026-02-30rolls forward to 2 March. - Step 10.
Read the two stored columns back under two process zones.
node --no-warnings read.mjs; $env:TZ = 'America/New_York'; node --no-warnings read.mjsreader zone Europe/Kiev id sent day column (DATE) moment column (INTEGER) moment as a day here 1 "2026-03-08" "2026-03-08" string 1772928000000 2026-03-08 2 "08/03/2026" "08/03/2026" string 1785704400000 2026-08-03 3 "2026-02-30" "2026-02-30" string 1772409600000 2026-03-02 reader zone America/New_York id sent day column (DATE) moment column (INTEGER) moment as a day here 1 "2026-03-08" "2026-03-08" string 1772928000000 2026-03-07 2 "08/03/2026" "08/03/2026" string 1785704400000 2026-08-02 3 "2026-02-30" "2026-02-30" string 1772409600000 2026-03-01Three rows, two readers, every moment column a different calendar day. The
DATEcolumn hands back astringand survives both. Stop the endpoint by its process id.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| el.value is 2026-03-08 while the field shows the month before the day | Display follows the browser interface language, the value never does | Assert on el.value, and take bug reports that quote a date on screen as ambiguous until you read the value. |
| el.value empty and valueAsNumber NaN after an assignment | The control refused the string and reports itself empty | Look for the assignment that failed, not for a validation rule. A required field will report valueMissing instead. |
| badInput false on every value tried | The control refused each string instead of holding it as bad input | Do not assert on badInput for a date field. Assert on value === ''. |
| stepMismatch true on a date inside min and max | step counts days from min, so most dates in the range are invalid | Confirm the step base is the one the product means, and test the day before and the day after each valid date. |
| toISOString() one day before the value that was sent | The string was parsed as local time and serialised as UTC | Store the calendar day as a string, or construct with Date.UTC and keep the moment. Decide which the column means. |
| A stored moment renders a different day in two zones | The column keeps an instant, and a calendar day is not an instant | Move the field to a date column, or pin the zone the report renders in and write it down. |
| The route stored 2026-02-30 | new Date() rolls an overflowing day forward without an error | Validate the shape and the calendar day on the server before parsing. |
Common mistakes
What to check next
- How to check required field validation: the flag a refused date sets.
- How to test that client validation is enforced on the server: the general case behind step 9.
- HTML input pattern validation: the same gap on a text field.
- Input type number validation: the same
min,maxandstepmachinery. - How to test date format in different locales: which format to render the value in.
- How to test timezone handling: the zone side of steps 5, 7 and 10.
FAQ
How do I check if a date is valid?
Read el.value: an invalid string leaves it empty, as step 3 shows. On the server, reject the string before parsing: step 9 shows new Date('2026-02-30') returning 2 March, not an error.
How do I check if a date input is empty?
Compare el.value with the empty string, or read validity.valueMissing on a required field. A never touched field and one given a bad string both report empty, so neither says which.
Why does my date arrive one day earlier on the server?
The value was parsed as local time somewhere and serialised as UTC, or the reverse. Steps 5 and 7 show four expressions producing two calendar days.
Should a date be stored as a date or as a timestamp?
Step 10 answers it for your data. A due date is a calendar day and keeps its day in a date column. An event is a moment and needs a zone.
Verified
Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2node:sqlite 3.51.3curl 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