Dates
Date validation by regex is famously tricky — February has fewer days, leap years exist, formats vary by region. The practical answer mirrors emails and URLs: regex for shape, a real date parser (Date.parse, dayjs, chrono) for semantics, and explicit storage in ISO 8601.
Patterns + Date parsers + validation
EXAMPLE
// 1) Why pure regex is the wrong tool
// Regex can't tell you that 2024-02-30 is invalid (Feb has 29 days max).
// Use regex to check SHAPE; use a date library to check SEMANTICS.
// 2) Shape patterns — common formats
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; // 2024-01-15
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
const SLASH_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/; // 1/15/24, 15/1/2024
const DOT_DATE_RE = /^\d{1,2}\.\d{1,2}\.\d{4}$/; // German style 15.1.2024
const US_DATE_RE = /^(?:0?[1-9]|1[0-2])\/(?:0?[1-9]|[12]\d|3[01])\/\d{4}$/;
const EU_DATE_RE = /^(?:0?[1-9]|[12]\d|3[01])\/(?:0?[1-9]|1[0-2])\/\d{4}$/;
// 3) Validating + parsing — combined helper
function parseIsoDate(input) {
if (!ISO_DATE_RE.test(input)) return null;
const d = new Date(input + 'T00:00:00Z');
if (isNaN(d.getTime())) return null;
// Confirm round-trip — rejects 2024-02-30 etc.
if (d.toISOString().slice(0, 10) !== input) return null;
return d;
}
parseIsoDate('2024-01-15'); // Date
parseIsoDate('2024-02-30'); // null — Feb doesn't have 30 days
parseIsoDate('2024-02-29'); // Date — 2024 is a leap year
parseIsoDate('2023-02-29'); // null — not a leap year
// 4) Leap-year-aware regex (academic exercise — DON'T ship this)
// It's HUGE and still doesn't handle every calendar rule.
//
// const VALID_DATE_PURE_RE = /^(?:(?:(?:1[6-9]|[2-9]\d)\d{2})-(?:(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01]))|(?:(?:0[469]|11)-(?:0[1-9]|[12]\d|30))|(?:02-(?:0[1-9]|1\d|2[0-8])))|(?:(?:(?:1[6-9]|[2-9]\d)(?:0[48]|[2468][048]|[13579][26])|(?:16|[2468][048]|[3579][26])00)-02-29))$/;
//
// Notice the awkward 'is leap year' logic. The lesson: use a parser.
// 5) Practical approach — Date.parse + verification
function parseDateFlexible(input) {
// dayjs handles many formats; install via npm install dayjs
// import dayjs from 'dayjs';
// const d = dayjs(input);
// if (!d.isValid()) return null;
// return d.toDate();
// Vanilla
const d = new Date(input);
return isNaN(d.getTime()) ? null : d;
}
// 6) Library choices
// • Day.js — small, immutable, modern
// • date-fns — modular, tree-shakeable
// • Luxon — comprehensive, timezone-aware
// • Moment.js — legacy; avoid for new code
// • chrono-node — natural-language parsing ('next Friday')
// • Native Date — limited but free
// 7) Extracting dates from text
const FIND_ISO_DATES = /\d{4}-\d{2}-\d{2}/g;
'Meet at 2024-01-15 or 2024-02-30 (invalid)'.match(FIND_ISO_DATES);
// ['2024-01-15', '2024-02-30'] — match doesn't validate
// Filter using a real parser
const valid = [];
for (const s of 'Meet at 2024-01-15 or 2024-02-30'.match(FIND_ISO_DATES) ?? []) {
const d = parseIsoDate(s);
if (d) valid.push(d);
}
// 8) Common formats reference
// ISO 8601 date: 2024-01-15
// ISO 8601 datetime: 2024-01-15T03:21:00Z (always UTC with Z)
// RFC 3339 datetime: 2024-01-15T03:21:00+10:00
// RFC 2822 (email): Mon, 15 Jan 2024 03:21:00 +0000
// ANSI SQL: 2024-01-15 03:21:00
// Unix timestamp: 1705291260
// 9) Time-only patterns
const TIME_24_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/; // 23:59 or 23:59:59
const TIME_12_RE = /^(?:0?[1-9]|1[0-2]):[0-5]\d(?:\s?[ap]m)?$/i; // 1:30, 11:59 pm
const HM_24 = /^([01]\d|2[0-3]):([0-5]\d)$/;
TIME_24_RE.test('23:59'); // true
TIME_24_RE.test('24:00'); // false
TIME_12_RE.test('11:59 PM'); // true
// 10) Duration patterns (ISO 8601 duration)
const DURATION_RE = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
DURATION_RE.test('P1Y2M3DT4H5M6S'); // true (1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds)
DURATION_RE.test('PT30M'); // true (30 minutes)
DURATION_RE.test('P3D'); // true (3 days)
// 11) Detection patterns (multiple formats)
function normaliseDate(input) {
const fmts = [
{ re: /^(\d{4})-(\d{2})-(\d{2})$/, build: (m) => `${m[1]}-${m[2]}-${m[3]}` },
{ re: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, build: (m) => `${m[3]}-${m[2].padStart(2,'0')}-${m[1].padStart(2,'0')}` }, // d/m/y
{ re: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, build: (m) => `${m[3]}-${m[2].padStart(2,'0')}-${m[1].padStart(2,'0')}` },
];
for (const f of fmts) {
const m = input.match(f.re);
if (m) return parseIsoDate(f.build(m));
}
return null;
}
normaliseDate('15/01/2024'); // Date for 2024-01-15
normaliseDate('15.01.2024');
normaliseDate('2024-01-15');
// 12) Timezone handling
// Storing dates as ISO 8601 with timezone (Z or +offset) is unambiguous.
// Naive dates (no timezone) are ambiguous — same string interpreted differently per server.
// For 'date only' (birthday, deadline), store as YYYY-MM-DD; compare without time.
// 13) Real-world tips
// • Accept many formats from users; store ONE canonical (ISO 8601)
// • Display in the user's locale on the client; use Intl.DateTimeFormat
// • Server-side: always work in UTC; convert at the edges
// • For 'date pickers', use a real component (HTML5 input type=date or a library)
// • Don't parse 'free text' dates without a real parser
// 14) UI feedback
function validateDate(input, options = { format: 'iso', minYear: 1900, maxYear: 2100 }) {
const d = parseIsoDate(input);
if (!d) return { ok: false, error: 'Use YYYY-MM-DD format' };
const y = d.getUTCFullYear();
if (y < options.minYear) return { ok: false, error: `Year must be >= ${options.minYear}` };
if (y > options.maxYear) return { ok: false, error: `Year must be <= ${options.maxYear}` };
return { ok: true, value: d };
}
// 15) Common bugs
// • Regex passing 2024-02-30 → silently invalid; round-trip with toISOString
// • US vs EU format ambiguity (1/2/2024 = Jan 2 or Feb 1) → use ISO
// • Year 2-digit (24 vs 1924) → ambiguous; require 4-digit
// • Time zone confusion — store UTC, display local
// • Date arithmetic with new Date(2024, 0, 32) → silently becomes Feb 1; check explicitly
// • Native Date.parse on non-standard strings — works on Chrome, fails on Safari/Firefox
// • Leap year regex DOESN'T cover century rule (1900 not a leap year, 2000 was)
// • Storing dates without timezone → 'this happened on a different day in Sydney vs LA'
Why it matters
Use regex to enforce the shape (^\d{4}-\d{2}-\d{2}$), then a real date parser to verify the day actually exists (round-trip with toISOString). Store ISO 8601 (with timezone where applicable), display in the user’s locale, and reach for Day.js / date-fns / Luxon instead of custom leap-year regex.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…