Skip to main content

Representing Dates & Times

Date is the famously sharp-edged built-in. Most bugs come from one of three things: 0-indexed months, UTC-vs-local parsing, or treating a Date as 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 Z or 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 timeZoneDate'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 likeActuallyWhy
new Date(2025, 1, 1)Feb 1month is 0-indexed
new Date("2025-01-01")UTC midnightdate-only ISO is UTC
new Date("2025-01-01T00:00")local midnighttime without Z is local
new Date(99, 0)year 19990–99 maps to 1900s
getTimezoneOffset() in IST-330sign is inverted
d1 === d2falseobjects, compare .getTime()
d1 + d2a string+ coerces Date to string
new Date("bad")Invalid Dateparse fails without throwing
+86400000 ms across DSTwrong houruse setDate for calendar math

Resources