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.
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.
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.valueis""andvalidity.badInputistrue. TreatbadInputas its own state with its own message; do not report it as “required”. valueAsDateis UTC midnight. Reading it withgetDate()in a negative-offset zone gives the previous day. UsegetUTCDate()or, better, stick toinput.value.- “Today” moves. A page left open past midnight has stale
minvalues. Recompute onfocusof the field, or onvisibilitychange, 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.
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
| 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.
Related Guides
- Age Verification and Date of Birth Validation — birthdays, minimum ages and split fields.
- Validating Date Input Min, Max and Format — the native date input in depth.
- Validating Time Slots and Business Hours — instants, zones and opening hours.
- Validating Date Range: Start Before End — two-field date rules.
← Back to Validating Common Input Types