Date and Time Validation

Date fields generate a disproportionate share of validation bugs, and almost none of them are about the rules themselves. “Must be in the future”, “must be 18 or older” and “must be during opening hours” are simple to state. What breaks is everything around them: a Date parsed from "2026-09-18" that silently becomes 17 September in California, an age check that is off by one on birthdays, a min attribute written in the wrong format that the browser ignores without complaint, a booking accepted for 02:30 on the night the clocks go forward — an hour that does not exist. This topic builds date and time validation on a few firm rules: keep calendar dates as ISO strings, compare them as strings or as plain year-month-day numbers, bring time zones in only when a real instant is involved, and let the native constraint model carry the verdict through the Constraint Validation API with the site’s usual <form novalidate> baseline.

The specific pain this solves is the class of bug that passes every test written in the developer’s own time zone and fails for users elsewhere — or for everyone, on one day a year.

Calendar dates versus instants Two columns contrasting calendar dates such as birthdays and deadlines with instants such as appointment start times, and how each should be validated. Calendar date • a day on a calendar: birthday, due date • stored as "2026-09-18" ✓ compare as strings or Y/M/D numbers ✗ new Date("2026-09-18") shifts it by time zone Instant • a moment in time: 14:30 in Berlin • stored as ISO with offset or UTC ✓ compare as epoch milliseconds ✗ needs an explicit IANA time zone
Most form dates are calendar dates and need no time zone at all; only instants need one, and they need it explicitly.

Prerequisites for Date and Time Fields

Requirement Minimum version Why it is needed
TypeScript 5.0+ Branded string types for ISO dates
<input type="date"> Chrome 20, Firefox 57, Safari 14.1 Native picker plus min/max/step constraints
<input type="time"> / datetime-local Chrome 20, Firefox 57/93, Safari 14.1 Native time entry with rangeUnderflow / rangeOverflow
Intl.DateTimeFormat All browsers Formatting dates in messages in the user’s locale
Temporal or a date library Temporal: Firefox 139+, Chrome 144+; polyfill elsewhere Time-zone-aware arithmetic for instants
IANA time zone for the business Opening hours and appointment slots belong to a zone, not an offset

Date and Time Constraint API Reference

API Type Effect Notes
min / max on type=date "YYYY-MM-DD" string Sets rangeUnderflow / rangeOverflow Wrong format is silently ignored
step on type=time seconds Sets stepMismatch step="900" for 15-minute slots
input.value "YYYY-MM-DD" or "" ISO date, never locale-formatted Empty string when incomplete or invalid
input.valueAsDate Date | null UTC midnight of the date Read with getUTC* methods only
input.valueAsNumber number Epoch ms (date) or ms since midnight (time) NaN when empty
validity.badInput boolean Partially typed or impossible date The only signal for “31 February” typed in the native field
Intl.DateTimeFormat(loc, opts).format() string Localised display Pass timeZone: "UTC" when formatting valueAsDate

Step-by-Step Implementation

1. Use native date inputs with ISO min and max

<form id="booking" novalidate>
  <label for="start">Start date</label>
  <input id="start" name="start" type="date" required aria-describedby="start-hint start-err">
  <p id="start-hint" class="hint">Bookings open from tomorrow and up to 12 months ahead.</p>
  <p id="start-err" class="field-error" hidden></p>
</form>
/** Today's calendar date in the user's zone, as YYYY-MM-DD — without UTC drift. */
export function todayISO(now = new Date()): string {
  const y = now.getFullYear();
  const m = String(now.getMonth() + 1).padStart(2, "0");
  const d = String(now.getDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}

export function addDaysISO(iso: string, days: number): string {
  const [y, m, d] = iso.split("-").map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d + days));   // UTC arithmetic: no DST surprises
  return dt.toISOString().slice(0, 10);
}

const start = document.querySelector<HTMLInputElement>("#start")!;
start.min = addDaysISO(todayISO(), 1);
start.max = addDaysISO(todayISO(), 365);

toISOString().slice(0, 10) on the current time is the classic bug — it gives the UTC date, which is tomorrow for users east of Greenwich in the evening and yesterday for users west of it in the morning. Build “today” from local getters instead; do arithmetic in UTC only on values that are already pure dates.

2. Turn native flags into specific messages

const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: "long", timeZone: "UTC" });
const show = (iso: string) => fmt.format(new Date(`${iso}T00:00:00Z`));

export function dateMessage(input: HTMLInputElement): string {
  const v = input.validity;
  if (v.badInput) return "Enter a real date, for example 18 September 2026.";
  if (v.valueMissing) return "Enter a start date.";
  if (v.rangeUnderflow) return `Choose ${show(input.min)} or later.`;
  if (v.rangeOverflow) return `Choose ${show(input.max)} or earlier.`;
  return "";
}

const form = document.querySelector<HTMLFormElement>("#booking")!;
form.addEventListener("submit", (event) => {
  for (const el of form.querySelectorAll<HTMLInputElement>("input[type=date]")) {
    el.setCustomValidity("");                      // reset, then re-read the native flags
    const msg = dateMessage(el);
    if (msg) el.setCustomValidity(msg);
  }
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});

The native messages (“Value must be 19/09/2026 or later”) are functional but vary by browser and cannot be localised by you. Mapping the ValidityState flags to your own copy follows the pattern in reading ValidityState flags for granular errors. The full native-input recipe is validating date input min, max and format.

3. Compare calendar dates as strings

ISO dates sort lexicographically in chronological order, so comparing two YYYY-MM-DD strings with < is correct, fast and time-zone-free — as long as both are zero-padded ISO values straight from input.value.

export const isBefore = (a: string, b: string) => a < b;         // "2026-09-18" < "2026-10-01"
export const isSameOrAfter = (a: string, b: string) => a >= b;

That makes cross-field rules like “end date on or after start date” one line, which is why validating date range start before end uses plain string comparison throughout.

4. Bring in time zones only for instants

When a value is a real moment — an appointment at 14:30 at a clinic in Berlin — combine the date, the time and the business’s IANA zone into an instant, and validate against rules expressed in that zone.

import { Temporal } from "@js-temporal/polyfill";   // or the native global where available

export function toInstant(dateISO: string, timeHHMM: string, zone: string): Temporal.ZonedDateTime {
  return Temporal.PlainDateTime.from(`${dateISO}T${timeHHMM}`).toZonedDateTime(zone, { disambiguation: "reject" });
}

disambiguation: "reject" throws for times that do not exist (the skipped hour when clocks go forward) and for ambiguous ones (the repeated hour when they go back), which turns a silent data bug into a message you can show. Opening hours and slot rules are covered in validating time slots and business hours.

From typed values to a validated instant A calendar date and a wall-clock time from two inputs are combined with the business's IANA time zone into a zoned instant, which is then checked against opening hours and lead time. Date input 2026-03-29 Time input 02:30 Business zone Europe/Berlin ZonedDateTime reject if nonexistent Rules hours, lead time, slots
The time zone belongs to the business, not the browser; nonexistent and ambiguous wall-clock times are rejected rather than guessed.

State Management and Edge Cases

Date fields have more hidden states than most inputs, because the native control can hold a partially typed value that the page cannot see.

  • Partial input is invisible. While the user has typed a day and month but not a year, input.value is "" and validity.badInput is true. Treat badInput as its own state with its own message; do not report it as “required”.
  • valueAsDate is UTC midnight. Reading it with getDate() in a negative-offset zone gives the previous day. Use getUTCDate() or, better, stick to input.value.
  • “Today” moves. A page left open past midnight has stale min values. Recompute on focus of the field, or on visibilitychange, if your rules reference today.
  • Cross-field dates change together. When the start date moves, re-validate the end date; the dependency pattern is the same as in cross-field validation strategies.
Native date input states A native date input moves from empty, to partially typed with badInput true, to a complete value that is either in range or out of range. empty partial (badInput) complete in range out of range type day or month year typed min ≤ value ≤ max underflow or overflow edit
While a date is partially typed the value is an empty string and badInput is true — a state that must get its own message.

Accessibility Compliance for Date Fields

Native date inputs are broadly accessible — each segment is a spin button with a name, and the picker is keyboard-operable — but their usability with screen readers varies, and many users type faster than they pick. Two patterns are both conformant: the native input, and three labelled text fields (day, month, year) grouped in a <fieldset> with a <legend>. The three-field pattern is the better choice for dates people know by heart, such as dates of birth, where scrolling a calendar back decades is painful; see age verification and date of birth validation.

Whichever you use, WCAG 3.3.2 Labels or Instructions requires the expected format to be stated when you accept typed input (“For example, 27 3 2007”), and 3.3.3 Error Suggestion requires range errors to name the allowed range — “Choose 19 September 2026 or later” — rather than “Invalid date”. 1.3.5 Identify Input Purpose applies to birth dates: use autocomplete="bday" (or bday-day, bday-month, bday-year on split fields) so the browser can fill them.

<fieldset aria-describedby="dob-hint dob-err">
  <legend>Date of birth</legend>
  <p id="dob-hint" class="hint">For example, 27 3 2007</p>
  <label for="dob-day">Day</label>
  <input id="dob-day" inputmode="numeric" autocomplete="bday-day" maxlength="2">
  <label for="dob-month">Month</label>
  <input id="dob-month" inputmode="numeric" autocomplete="bday-month" maxlength="2">
  <label for="dob-year">Year</label>
  <input id="dob-year" inputmode="numeric" autocomplete="bday-year" maxlength="4">
  <p id="dob-err" class="field-error" hidden></p>
</fieldset>

Common Gotchas and Debugging

Parsing an ISO date with new Date(). Date-only ISO strings are parsed as UTC midnight, then displayed in local time.

// Before: shows 17 September in the Americas
new Date("2026-09-18").toLocaleDateString();
// After: format as a calendar date by pinning the zone
new Intl.DateTimeFormat(undefined, { timeZone: "UTC" }).format(new Date("2026-09-18"));

Setting min in a locale format. input.min = "18/09/2026" is ignored silently; only YYYY-MM-DD works. If range errors never fire, check the attribute value in DevTools first.

Age computed from milliseconds. (now - dob) / (365.25 * 86400000) is wrong around birthdays and leap years. Compare year, month and day.

Adding days with local setDate() across DST. Local-time arithmetic around a clock change can land on 23:00 of the previous day. Do date arithmetic in UTC on pure dates, or with Temporal.PlainDate.

Booking times without a zone. Storing "2026-03-29T02:30" with no zone makes it impossible to know later which instant was meant, and that time does not exist in Berlin anyway. Store instants with their zone or as UTC plus the zone name.

Browser Compatibility Matrix

Date and time input support Support across Chromium, Firefox and Safari for native date and time inputs and the Temporal API. Chromium Firefox Safari type=date + min/max ✓ Yes ✓ Yes ✓ 14.1+ type=time + step ✓ Yes ✓ Yes ✓ 14.1+ datetime-local ✓ Yes ✓ 93+ ✓ 14.1+ showPicker() ✓ 99+ ✓ 101+ ✓ 16+ Temporal (native) 144+ ✓ 139+ ✗ No
Native date and time inputs work everywhere current; Temporal is arriving engine by engine, so ship a polyfill for instant arithmetic.
Feature Chromium Firefox Safari Fallback
type="date" Yes Yes 14.1+ Three text fields with the same validation
badInput on partial dates Yes Yes Yes
showPicker() 99+ 101+ 16+ Let the native button open it
Temporal 144+ 139+ Not yet @js-temporal/polyfill, loaded only where needed

Testing Date Rules With a Fixed Clock

Date validation that reads the real clock cannot be tested reliably: a test for “tomorrow is the earliest bookable day” passes today and fails at 23:59, and a test for “must be 18” breaks on the day your fixture birthday comes round. Every date rule on this page therefore takes now as a parameter with a default, so unit tests pass an explicit instant and the production code passes nothing. For end-to-end tests, freeze the browser clock instead of the function argument; Playwright’s clock API does exactly that, and you can also set the browser’s time zone per test to catch the drift bugs described above.

import { test, expect } from "@playwright/test";

test.use({ timezoneId: "America/Los_Angeles" });   // west of UTC: the drift-prone side

test("earliest start date is tomorrow in the user's zone", async ({ page }) => {
  await page.clock.setFixedTime(new Date("2026-09-18T22:30:00-07:00")); // late evening, already 19th in UTC
  await page.goto("/booking");
  await expect(page.getByLabel("Start date")).toHaveAttribute("min", "2026-09-19");
});

Run the same spec with timezoneId set to Pacific/Kiritimati (UTC+14) and Pacific/Pago_Pago (UTC−11). Between them, those two zones expose nearly every “off by one day” bug, because at most times of day they are on different calendar dates from UTC. Add one test on a daylight-saving transition date for any rule involving times, using the business’s zone. This is the same fixed-input discipline as the table-driven suites in unit testing validation logic, applied to the one input — time — that tests usually forget is an input at all.

Validating and Storing Dates on the Server

The server receives dates as strings and must treat them exactly as carefully as the client. Parse calendar dates with a strict ^\d{4}-\d{2}-\d{2}$ check followed by a round-trip test — construct the date and confirm the year, month and day come back unchanged — which rejects 2026-02-30 that a lenient parser would roll over into March. Recompute “today” on the server in the business’s time zone for rules like “no bookings in the past”, because the server’s own zone is usually UTC and the user’s zone is unknown. Store calendar dates in a date column, not a timestamp, and instants as UTC timestamps plus the IANA zone name they were entered in, so a later change to daylight-saving rules cannot shift a booking.

export function parseCalendarDate(s: string): string | null {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
  const [y, m, d] = s.split("-").map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d));
  const ok = dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
  return ok ? s : null;                       // "2026-02-30" rolls to March and is rejected
}

Share this parser with the client through a common module, as described in shared client–server schemas, so a date the browser accepted cannot be rejected by the server for a reason the user was never shown.

Localising Dates in Validation Messages

Error messages that include dates should display them the way the user reads dates, not the way your code stores them. “Choose 2026-09-19 or later” is readable but foreign to most people; “Choose 19 September 2026 or later” works in British English, while an American user expects “September 19, 2026”. Intl.DateTimeFormat with dateStyle: "long" produces the right form for the page’s locale, and passing timeZone: "UTC" when formatting a pure calendar date prevents the display drift described earlier. Avoid purely numeric formats such as 09/10/2026 in messages: they mean 9 October in most of the world and 10 September in the United States, and a validation message is the worst place for ambiguity.

Relative Rules: Lead Times, Windows and Deadlines

Many date rules are relative to today rather than fixed: “at least two working days from now”, “within the next 90 days”, “before the end of the tax year”. Express them as functions that produce ISO min and max strings, set those on the native input so the picker greys out invalid days, and keep the function as the source of truth for the error message. Working-day rules need a holiday calendar for the business’s country; fetch it once, cache it, and treat a failed fetch as “no holidays known” rather than blocking the form, then let the server apply the authoritative calendar. The user benefits twice: the picker prevents most mistakes, and the message explains the rest in terms of the rule (“Choose a date at least 2 working days from today”) rather than a bare range.

Choosing Between Native and Custom Date Pickers

A custom date picker is one of the most expensive components to make accessible, and most of the validation value comes from the constraint model, not the calendar. Start with the native input; its keyboard support, localisation and mobile pickers are free. Reach for a custom picker only for requirements the native one cannot meet — disabling specific days such as holidays or fully booked dates, showing prices per day, or selecting ranges in one control — and even then keep a typed input underneath so the value, min, max and your custom rules still flow through setCustomValidity(). A picker that only allows valid days is a convenience; the validation must still hold when the user types a disabled day directly.

Frequently Asked Questions

Why does my date show one day earlier than the user picked?

new Date("2026-09-18") parses a date-only string as UTC midnight, which is the previous evening in time zones west of UTC. Keep calendar dates as ISO strings, compare them as strings, and format with timeZone: "UTC" when you must go through a Date.

What format do min and max need on a date input?

Always YYYY-MM-DD, regardless of the user's locale or how the field displays. Any other format is ignored silently, so range checks never fire.

How do I detect a partially typed date in a native date input?

The value is an empty string while the date is incomplete, and validity.badInput is true. Check badInput before valueMissing so the message says "enter a real date" rather than "enter a date".

When do I need a time zone for date validation?

Only when the value is an instant, such as an appointment time. Birthdays, due dates and other calendar dates need no time zone. For instants, use the business's IANA zone, not the browser's.

← Back to Validating Common Input Types

Explore This Section