new Date("1990-05-14") is midnight UTC, which in New York is the evening of 13 May — so a date of birth typed as the 14th is stored, displayed and validated as the 13th, and an “at least 18 years old” check fails for someone on their eighteenth birthday.
Date validation bugs almost always come from mixing two different things: calendar dates (a day on a calendar, with no time or zone — birthdays, due dates, check-in days) and instants (a point in time — a booking at 14:00 in Paris, a deadline at 23:59 in the organiser’s zone). This page, part of synchronous validation patterns, keeps them apart and validates each on its own terms.
Context and prerequisites
What each input type gives you:
<input type="date">—valueis"YYYY-MM-DD", a calendar date, no zone.valueAsDatereturns aDateat midnight UTC, which is where the off-by-one bugs start.<input type="datetime-local">—"YYYY-MM-DDTHH:mm", a wall-clock time with no zone. It means nothing until you say which zone.<input type="time">—"HH:mm", wall-clock only.
JavaScript’s Date is always an instant (milliseconds since the epoch). Parsing a date-only string treats it as UTC; parsing a date-time string without an offset treats it as local time. That asymmetry is in the ECMAScript specification and catches most teams at least once.
The core pattern: calendar-date helpers and zone-explicit comparisons
// ---- Calendar dates: never become Date objects --------------------------
export type CalendarDate = string; // "YYYY-MM-DD"
const RE = /^(\d{4})-(\d{2})-(\d{2})$/;
export function isValidCalendarDate(s: string): boolean {
const m = RE.exec(s);
if (!m) return false;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
// Round-trip through UTC to reject 2026-02-30 without involving local time.
const t = new Date(Date.UTC(y, mo - 1, d));
return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d;
}
/** Today's calendar date IN A GIVEN ZONE (the user's, or the business's). */
export function todayIn(timeZone: string, now = new Date()): CalendarDate {
// en-CA formats as YYYY-MM-DD; formatToParts would work equally well.
return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(now);
}
/** Age in whole years on a calendar date, with no time zone arithmetic. */
export function ageOn(birth: CalendarDate, on: CalendarDate): number {
const [by, bm, bd] = birth.split("-").map(Number);
const [oy, om, od] = on.split("-").map(Number);
return oy - by - (om < bm || (om === bm && od < bd) ? 1 : 0);
}
// ---- Validators ---------------------------------------------------------
export function validateDateOfBirth(value: string, userZone: string, now = new Date()): string | null {
if (!value) return "Enter your date of birth.";
if (!isValidCalendarDate(value)) return "Enter a real date, like 1990-05-14.";
const today = todayIn(userZone, now);
if (value > today) return "Date of birth must be in the past."; // string compare is safe for YYYY-MM-DD
if (ageOn(value, today) < 18) return "You must be 18 or over to apply.";
return null;
}
/** A wall-clock time the user picked, interpreted in the EVENT's zone, as an instant. */
export function validateAppointment(local: string, eventZone: string, now = new Date()): string | null {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(local)) return "Choose a date and time.";
const instant = zonedWallTimeToInstant(local, eventZone);
if (instant === null) return "That time does not exist in this time zone (clocks change). Choose another.";
if (instant.getTime() <= now.getTime()) return "Choose a time in the future.";
return null;
}
declare function zonedWallTimeToInstant(local: string, timeZone: string): Date | null; // via Temporal or a tz library
With the Temporal API (or a polyfill), zonedWallTimeToInstant is Temporal.PlainDateTime.from(local).toZonedDateTime({ timeZone, disambiguation: "reject" }), which throws for non-existent times in a daylight-saving gap — exactly the case the validator reports.
Step-by-step walkthrough
- Classify every date field. Birthdays, due dates and check-in days are calendar dates; appointments, deadlines and timestamps are instants. Write the classification next to the field.
- Keep calendar dates as strings. Read
input.value, notvalueAsDate. Validate the format and that the date exists, without converting to aDatein local time. - Compute “today” in an explicit zone. “Must be in the past” depends on whose today — usually the user’s, sometimes the business’s (a hotel’s check-in date is in the hotel’s zone).
- Compare calendar dates as
YYYY-MM-DDstrings. Lexicographic order equals chronological order for zero-padded ISO dates, with no zone involved. - Convert wall times to instants with a named zone. A
datetime-localvalue plus the event’s zone produces an instant; handle non-existent and ambiguous times at daylight-saving changes. - Send both forms to the server. For instants, send ISO with offset (or UTC plus the zone name). For calendar dates, send the plain string. The server’s schema — see sharing one Zod schema between client and server — should use a date-string type, not a
Date.
Why “today” is not a single value
At 23:30 in Los Angeles on 13 May it is already 14 May in London and the afternoon of 14 May in Sydney. A validator that asks “is this date in the past?” must choose a reference zone, and the right choice depends on the domain. For a user’s own date of birth, their zone is natural. For a booking at a venue, the venue’s zone decides whether a date has passed. For a legal deadline, the jurisdiction’s zone applies. Encoding that choice as an explicit parameter — rather than letting new Date() silently use the device’s zone — is what makes the rule correct for users who are travelling, whose device clock is set to another zone, or who simply live far from your servers.
Failure modes and edge cases
1. valueAsDate and new Date("YYYY-MM-DD")
Both produce midnight UTC. Displaying with toLocaleDateString() in a zone west of UTC shows the previous day. Avoid them for calendar dates.
2. Tests that pass in one zone
A test suite run in UTC on CI and in America/Los_Angeles locally will disagree on these bugs. Run date tests with the TZ environment variable set to several zones (including one with a half-hour offset such as Asia/Kolkata), and pass now explicitly.
3. Daylight-saving gaps and overlaps
02:30 on the spring-forward date does not exist in many zones; 01:30 on the fall-back date happens twice. Reject non-existent times with a clear message; for ambiguous times, pick a policy (earlier, later, or ask).
4. Date ranges
A range where the end must not be before the start compares two calendar dates as strings, or two instants as numbers — never a mix. Cross-field handling is in validating a date range: start before end.
5. Dirty checks and date formats
A picker that emits a Date and a baseline stored as a string will always compare as changed. Normalise both to the calendar string before comparing, as in deep equality for dirty detection on nested values.
Verification checklist
Frequently Asked Questions
Should I use a date library?
For calendar dates, plain strings plus the helpers above are enough. For instants with zones and daylight saving, use the Temporal API where available (or its polyfill) or a zone-aware library; hand-rolling zone conversions is where subtle bugs hide.
Is type="date" good for dates of birth?
Native date pickers make distant years slow to reach and vary in accessibility. For dates of birth, three labelled text inputs (day, month, year) are often easier; they still produce a calendar date string. Keyboard behaviour is covered in keyboard-accessible date inputs.
How should the server store calendar dates?
As a DATE column (or equivalent) with no time zone, not as a timestamp. Storing a birthday as a timestamp reintroduces the conversion bugs at the database layer.