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

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

  1. Step 1.

    Put one date in the field and read the three properties that carry it.

    node probe.mjs yield
    
    the 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. valueAsDate is a moment at UTC midnight, which seeds every off-by-one-day report below.

  2. 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 . Рік=2026

    Segment order and separator follow the interface language; el.value does not move. What a person reads off the screen is not what the form sends.

  3. Step 3.

    Assign five strings the control cannot hold, and one it can.

    node probe.mjs bad
    
    assigned        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         true

    Six assignments produce one state. The control stores nothing it cannot parse, so it reports itself empty rather than wrong and badInput stays false.

  4. Step 4.

    Measure min, max and step on the second field.

    node probe.mjs step
    
    min 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."

    step counts days from min, so six days in seven set stepMismatch and the field still holds the value. 2026-03-01 sets two flags while the message names one.

  5. Step 5.

    Parse the yielded string four ways in the machine zone.

    node parse.mjs 2026-03-08
    
    process 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:00

    A 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.

  6. 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/Kiev

    The 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.

  7. Step 7.

    Set the zone from PowerShell and parse again.

    $env:TZ = 'America/New_York'; node parse.mjs 2026-03-08
    
    process 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:00

    The 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.

  8. Step 8.

    Submit the form and read what the route parsed.

    node probe.mjs submit
    
    form.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.

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

    Two of four are stored. 08/03/2026, the string step 2 displayed under en-US, is read as 3 August. 2026-02-30 rolls forward to 2 March.

  10. 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.mjs
    
    reader 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-01

    Three rows, two readers, every moment column a different calendar day. The DATE column hands back a string and 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

Sign: A tester reads 08/03/2026 off the screen, files it as the value, and the developer cannot reproduce.Cause: Step 2 shows the same el.value of 2026-03-08 rendered month first with slashes under --lang=en-US and day first with dots, and the segment names in Ukrainian, under --lang=uk. The screen is a rendering of the value in the browser interface language. Step 9 then posts 08/03/2026 as a literal string and the route reads it as 3 August, so the two spellings are a real defect when anything but the control produces the value.
Sign: A date validation test passes on 2026-02-30 and on 2026-13-01, and the suite is reported as covering invalid dates.Cause: Step 3 shows the control leaving el.value empty for both, so a required field fails on valueMissing and the test never exercised a date rule. Step 9 sends the same two strings past the browser: 2026-13-01 is rejected by the server and 2026-02-30 is stored as 2026-03-02, because new Date() rolls an overflowing day into the next month. The browser and the parser disagree about which of the two is a date.
Sign: A run under another timezone reports the same results as the machine zone, and the zone coverage is signed off.Cause: Step 6 shows TZ=America/New_York node printing undefined for process.env.TZ and Europe/Kiev for the resolved zone in Git Bash on Windows. Git Bash strips the variable out of the child process, so the test ran in the machine zone with no error. PowerShell $env:TZ works, and step 7 uses it.
Sign: The API returns the right day in staging and the wrong day for one group of users in production.Cause: Step 10 reads one table twice. Row 1 holds 1772928000000 in an INTEGER column and renders 2026-03-08 in Europe/Kiev and 2026-03-07 in America/New_York, while the DATE column returns the string 2026-03-08 to both readers. A calendar day stored as a moment is a different day for anyone whose zone crosses UTC midnight, and staging is usually one zone.

What to check next

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.

intermediate12 minpublished updated Maks Verny