Representing Dates & Times
Dateis the famously sharp-edged built-in. Most bugs come from one of three things: 0-indexed months, UTC-vs-local parsing, or treating aDateas immutable. The new Temporal API fixes all of it.
A Date is just one number
Internally a Date is a single integer: milliseconds since the Unix epoch (1970-01-01 00:00:00 UTC). Everything else — years, months, "local time" — is computed from that number on demand. There is no stored timezone.
new Date(0); // => 1970-01-01T00:00:00.000Z the epoch itself
new Date(1000); // => 1970-01-01T00:00:01.000Z 1 second later
Date.now(); // => 1751155200000 current epoch ms (a number, no object)
new Date().getTime(); // => same value, via an object
Because it's one number, the timezone you see is applied only at display time. The underlying value is always UTC.
Creating a Date — four forms, three traps
new Date(); // now
new Date(1609459200000); // from an epoch-ms timestamp
new Date("2021-01-01"); // by parsing a string (trap below)
new Date(2021, 0, 1); // from components (month is 0-indexed!)
Date(); // WITHOUT new => a STRING, not a Date object
typeof Date(); // => "string"
Trap 1 — months are 0-indexed (but days aren't)
new Date(2025, 0, 1); // => Jan 1, 2025 month 0 = January
new Date(2025, 11, 25); // => Dec 25, 2025 month 11 = December
new Date(2025, 1, 1); // => Feb 1, 2025 NOT January!
Only the month is zero-based. Day-of-month is 1-based, hours/minutes/seconds are 0-based. Mixing these up is the single most common Date bug.
Trap 2 — string parsing silently switches between UTC and local
This is the one that ruins days. The same-looking string is interpreted differently based on tiny format details:
new Date("2025-01-01"); // => parsed as UTC midnight
new Date("2025-01-01T00:00:00"); // => parsed as LOCAL midnight (no "Z")
new Date(2025, 0, 1); // => LOCAL midnight (component form is always local)
A date-only ISO string ("2025-01-01") is UTC. Add a time but no Z, and it flips to local. So in any timezone behind UTC, new Date("2025-01-01").getDate() can return 31 (the previous day). Rules:
- ISO 8601 with
Zor an offset → unambiguous, always use this. - Non-ISO strings like
"Dec 25, 2025"or"12/25/2025"→ implementation-defined."12/11/2025"is Dec 11 in the US, Nov 12 elsewhere. Never parse these.
new Date("2025-12-25T00:00:00Z"); // => safe, explicit UTC
new Date("Dec 25, 2025"); // => works in most browsers, but not guaranteed
new Date("25/12/2025"); // => Invalid Date in Chrome (US-format expected)
Trap 3 — two-digit years map to the 1900s
new Date(99, 0, 1); // => Jan 1, 1999 not 0099!
new Date(50, 0, 1); // => Jan 1, 1950
new Date(1999, 0, 1); // => Jan 1, 1999 always pass the full year
Get vs UTC-get
Every component has a local getter and a UTC getter. They differ by your machine's offset:
const d = new Date("2025-01-01T00:00:00Z");
d.getHours(); // => depends on YOUR timezone (e.g. 5 in IST)
d.getUTCHours(); // => 0 always UTC
d.getFullYear(); // => 2025 (never getYear — that's the deprecated 1900-offset one)
d.getMonth(); // => 0 0-indexed again
d.getDate(); // => day of month, 1–31
d.getDay(); // => day of WEEK, 0=Sunday … 6=Saturday
d.getTime(); // => epoch ms
getDay() (weekday) vs getDate() (day-of-month) is an easy slip. And there's no setDay() — the weekday is derived, you can't set it directly.
The timezone offset has the opposite sign you'd expect
// running in India (UTC+5:30):
new Date().getTimezoneOffset(); // => -330 minutes, and NEGATIVE for east of UTC
It returns minutes to add to local time to get UTC, so zones ahead of UTC are negative. Counterintuitive — double-check the sign whenever you use it.
Dates are mutable, and overflow rolls over
setX methods mutate the date in place and return the new timestamp (not a new Date):
const d = new Date(2025, 0, 1);
d.setMonth(5); // mutates d to June 1, 2025
d; // => the SAME object, now changed
Out-of-range values roll over — which is the cleanest way to do date math:
new Date(2025, 0, 32); // => Feb 1, 2025 day 32 of Jan rolls forward
new Date(2025, 12, 1); // => Jan 1, 2026 month 12 rolls to next year
new Date(2025, 2, 0); // => Feb 28, 2025 day 0 = last day of previous month
// "last day of any month" trick:
new Date(2025, 3, 0).getDate(); // => 31 (day 0 of April = March 31)
// add a day, correctly, even across month ends:
const d2 = new Date(2025, 0, 31);
d2.setDate(d2.getDate() + 1); // => Feb 1, 2025
Date arithmetic
Subtraction works because - coerces each Date to its timestamp:
const start = new Date("2025-01-01");
const end = new Date("2025-01-08");
end - start; // => 604800000 difference in ms
(end - start) / 86_400_000; // => 7 days (ms per day = 24*60*60*1000)
But comparison operators behave inconsistently because of how Date coerces:
start < end; // => true < coerces both to numbers
start - end; // => a number (ms)
start + end; // => a STRING — "+" prefers string coercion for objects!
start === new Date("2025-01-01"); // => false two objects, never equal
start.getTime() === new Date("2025-01-01").getTime(); // => true compare timestamps
So: use </> directly, but for equality always compare .getTime().
The DST gotcha in arithmetic
Adding 86_400_000 ms is not always "the same time tomorrow" — across a daylight-saving boundary the wall-clock hour shifts:
const d = new Date(2025, 2, 9, 12); // noon, day DST starts (US)
new Date(d.getTime() + 86_400_000); // => 1:00 PM next day, not noon!
d.setDate(d.getDate() + 1); // => noon next day — setDate respects DST
Use setDate/setMonth for calendar math, raw ms only for elapsed-time math.
Invalid Date
A bad parse doesn't throw — it gives a special Date whose timestamp is NaN:
const bad = new Date("not a date");
bad; // => Invalid Date
bad.getTime(); // => NaN
bad instanceof Date; // => true it IS a Date, just invalid
// the only reliable validity check:
Number.isNaN(bad.getTime()); // => true
bad.toISOString(); // throws RangeError on Invalid Date
Formatting
const d = new Date("2025-12-25T09:30:00Z");
d.toISOString(); // => "2025-12-25T09:30:00.000Z" always UTC, machine-readable
d.toString(); // => "Thu Dec 25 2025 ..." in LOCAL time
d.toUTCString(); // => "Thu, 25 Dec 2025 09:30:00 GMT"
d.toLocaleDateString("en-US"); // => "12/25/2025"
d.toLocaleDateString("en-GB"); // => "25/12/2025"
d.toLocaleDateString("de-DE"); // => "25.12.2025"
For anything custom, use Intl.DateTimeFormat — and reuse the formatter (constructing one is expensive):
const fmt = new Intl.DateTimeFormat("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
hour: "2-digit", minute: "2-digit", timeZone: "Asia/Kolkata",
});
fmt.format(d); // => "Thursday, December 25, 2025 at 03:00 PM"
Intl.DateTimeFormat is the only built-in that takes an explicit timeZone — Date's own methods only know UTC and the machine's local zone.
Temporal — the replacement
Date is officially legacy. Temporal is the modern API: immutable, no 0-indexed months, explicit time zones, and a real distinction between "a calendar date," "a wall-clock time," and "an instant."
// months are 1-based, objects are immutable, time zones are first-class:
Temporal.PlainDate.from("2025-12-25"); // a date, no time/zone
Temporal.Now.zonedDateTimeISO("Asia/Kolkata"); // now, in a specific zone
Temporal.PlainDate.from("2025-12-25").add({ days: 7 }); // returns a NEW date
Still rolling out across engines (polyfill available today). Prefer it for new code; reach for a library like Luxon/date-fns until support is universal.
The gotcha table
| Looks like | Actually | Why |
|---|---|---|
new Date(2025, 1, 1) | Feb 1 | month is 0-indexed |
new Date("2025-01-01") | UTC midnight | date-only ISO is UTC |
new Date("2025-01-01T00:00") | local midnight | time without Z is local |
new Date(99, 0) | year 1999 | 0–99 maps to 1900s |
getTimezoneOffset() in IST | -330 | sign is inverted |
d1 === d2 | false | objects, compare .getTime() |
d1 + d2 | a string | + coerces Date to string |
new Date("bad") | Invalid Date | parse fails without throwing |
+86400000 ms across DST | wrong hour | use setDate for calendar math |
Resources
- MDN: Date · Date string parsing rules
- MDN: Temporal — the modern replacement
- Intl.DateTimeFormat — locale/timezone-aware formatting
- You Don't Need Moment.js — native + lightweight-library patterns