Dates with Date
Mental model: A Date holds one number: milliseconds since 1970 in UTC. Everything else, including the month you read back, is a rendering of that number in some time zone.
Level: intermediate · about 15 minutes
const stamp = Date.now(); // ms since 1970-01-01T00:00:00Z
console.log(typeof stamp); // 'number'
const d = new Date(0);
console.log(d.toISOString()); // '1970-01-01T00:00:00.000Z', the epoch
console.log(new Date(86400000).toISOString()); // '1970-01-02T00:00:00.000Z'
const now = new Date();
console.log(now.getTime() === Number(now)); // true, valueOf returns the number
console.log(now.getTime() === +now); // trueA Date is a number wearing a costume.
A Date instance stores a single integer: the number of milliseconds since the Unix epoch, in UTC. It has no time zone of its own. When you call getHours() or toString(), the runtime renders that number using the system time zone, which is why the same Date prints differently on your laptop and on your server.
one number (UTC ms)
1709596800000
|
+-------+--------+
| |
getUTCHours() getHours()
render in UTC render in the system zone
"00:00" "01:00" in Berlin, "19:00" in New York
| Constructor | Means | Time zone |
|---|---|---|
new Date() | right now | n/a |
new Date(1709596800000) | that exact instant | UTC by definition |
new Date(2024, 2, 5) | 5 March 2024, midnight | local, month is zero based |
new Date("2024-03-05") | 5 March 2024, midnight | UTC (date only ISO) |
new Date("2024-03-05T00:00") | 5 March 2024, midnight | local (no offset given) |
new Date("2024-03-05T00:00Z") | 5 March 2024, midnight | UTC (explicit offset) |
new Date("5 March 2024") | whatever the engine guesses | implementation defined |
new Date(other) | a copy | same instant |
const d = new Date(2024, 1, 1);
console.log(d.getMonth(), d.getDate());You passed month 1, which is February, and getMonth reads back the same zero based number, so 1. The date is 1 February 2024, not 1 January. Getters and setters agree with the constructor, so the only place the off by one hurts is in your head.
Parsing: the one string you can trust
// Date-only ISO strings are parsed as UTC.
console.log(new Date('2024-03-05').toISOString());
// '2024-03-05T00:00:00.000Z'
// Add a time with no offset and the meaning changes to local.
console.log(new Date('2024-03-05T00:00').getHours()); // 0, in YOUR zone
// An explicit offset removes all doubt.
console.log(new Date('2024-03-05T12:00:00+01:00').toISOString());
// '2024-03-05T11:00:00.000Z'
// Anything else is a guess, and NaN when the guess fails.
console.log(Date.parse('2024-13-45')); // NaN
console.log(new Date('nope').getTime()); // NaN
const bad = new Date('not a date');
console.log(bad instanceof Date); // true, it is still a Date
console.log(Number.isNaN(bad.getTime())); // true, this is the real check
console.log(String(bad)); // 'Invalid Date'
const isValidDate = (v) => v instanceof Date && !Number.isNaN(v.getTime());
console.log(isValidDate(bad), isValidDate(new Date(0))); // false true
try { bad.toISOString(); } catch (err) { console.log(err.constructor.name); } // RangeErrorDetecting an invalid date, and why typeof will not help.
Two sets of getters, and you must pick
const d = new Date('2024-03-05T23:30:00Z');
console.log(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); // 2024 2 5
console.log(d.getUTCHours(), d.getUTCMinutes()); // 23 30
// The local versions depend on where this code runs.
console.log(d.getFullYear(), d.getMonth(), d.getDate());
console.log(d.getHours(), d.getDay()); // getDay is 0=Sunday
console.log(d.getTimezoneOffset()); // minutes BEHIND UTC, sign is inverted
Formatting
const d = new Date('2024-03-05T14:07:09Z');
console.log(d.toISOString()); // '2024-03-05T14:07:09.000Z', always UTC
console.log(d.toISOString().slice(0, 10)); // '2024-03-05', the useful slice
console.log(d.toJSON()); // same as toISOString, used by JSON.stringify
console.log(JSON.stringify({ at: d })); // '{"at":"2024-03-05T14:07:09.000Z"}'
// For humans, never hand roll. Intl does every locale (lesson 9.7).
console.log(d.toLocaleDateString('en-GB', { dateStyle: 'long', timeZone: 'UTC' }));
// '5 March 2024'
Hand rolled, wrong twice
const pad = (n) => String(n).padStart(2, '0');
const ymd = `${d.getFullYear()}-${
pad(d.getMonth())}-${pad(d.getDate())}`;
// month is off by one, and
// this is local, not UTC
Deliberate
const pad = (n) => String(n).padStart(2, '0');
const ymd = `${d.getUTCFullYear()}-${
pad(d.getUTCMonth() + 1)}-${
pad(d.getUTCDate())}`;
// or simply d.toISOString().slice(0, 10)If you find yourself writing pad, check whether toISOString().slice(0, 10) already does the job. It is UTC, which is exactly right for a stored calendar date and exactly wrong for "today, where the user is".
Arithmetic: rollover is a feature
const d = new Date(Date.UTC(2024, 0, 31)); // 31 Jan 2024
const plus1 = new Date(d.getTime());
plus1.setUTCDate(plus1.getUTCDate() + 1);
console.log(plus1.toISOString().slice(0, 10)); // '2024-02-01', month rolled
const dayZero = new Date(Date.UTC(2024, 2, 0));
console.log(dayZero.toISOString().slice(0, 10)); // '2024-02-29', day 0 = last day of Feb
const daysInMonth = (y, m) => new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
console.log(daysInMonth(2024, 1), daysInMonth(2023, 1)); // 29 28The setters normalise out-of-range values for you.
const jan31 = new Date(Date.UTC(2024, 0, 31));
const next = new Date(jan31.getTime());
next.setUTCMonth(next.getUTCMonth() + 1);
console.log(next.toISOString().slice(0, 10)); // '2024-03-02', not 29 February
// If you want "clamp to the end of the month", do it yourself.
const addMonthClamped = (date, n) => {
const y = date.getUTCFullYear();
const m = date.getUTCMonth() + n;
const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
return new Date(Date.UTC(y, m, Math.min(date.getUTCDate(), last)));
};
console.log(addMonthClamped(jan31, 1).toISOString().slice(0, 10)); // '2024-02-29'Adding a month is not a well defined operation, and Date picks an answer you may not want.
Adding milliseconds
const tomorrow = new Date(
d.getTime() + 24 * 60 * 60 * 1000
);
// exactly 24 hours later.
// On a DST change that is
// 23:00 or 01:00 local, not
// the same wall clock time.
Adding a calendar day
const tomorrow = new Date(d.getTime());
tomorrow.setDate(tomorrow.getDate() + 1);
// same wall clock time,
// next calendar day, even
// across a DST change.Neither is wrong. "24 hours from now" and "this time tomorrow" are different questions, and twice a year they give different answers. Decide which one your feature means before you write the line.
const a = new Date('2024-03-05T00:00:00Z');
const b = new Date('2024-03-05T00:00:00Z');
console.log(a === b); // false, two different objects
console.log(a.getTime() === b.getTime()); // true, compare the numbers
console.log(+a === +b); // true, the terse version
console.log(a < new Date('2024-04-01T00:00:00Z')); // true, < and > coerce
const days = (x, y) => Math.round((y - x) / 86400000);
console.log(days(new Date('2024-02-28T00:00:00Z'), new Date('2024-03-01T00:00:00Z'))); // 2Comparing and diffing.
now, as a numberDate.now()copy a datenew Date(d.getTime())build a UTC instantnew Date(Date.UTC(y, m, day)), month zero basedstored calendar dated.toISOString().slice(0, 10)is it valid?!Number.isNaN(d.getTime())days in a monthnew Date(Date.UTC(y, m + 1, 0)).getUTCDate()compare- compare
getTime()values, never the objects show a humantoLocaleDateString(locale, options)
Try it yourself
Probe your own time zone
const winter = new Date('2024-01-15T12:00:00Z');
const summer = new Date('2024-07-15T12:00:00Z');
for (const d of [winter, summer]) {
console.log({
iso: d.toISOString(),
localHours: d.getHours(),
offsetMinutes: d.getTimezoneOffset(),
local: d.toString(),
});
}
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
Change the ISO string to a summer date and compare the offsets. Then work out what getTimezoneOffset returns for a zone ahead of UTC.
Calendar helpers
const iso = (d) => d.toISOString().slice(0, 10);
const addDays = (d, n) => {
const copy = new Date(d.getTime());
copy.setUTCDate(copy.getUTCDate() + n);
return copy;
};
const startOfMonth = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
const endOfMonth = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
const feb = new Date('2024-02-17T09:00:00Z');
console.log(iso(startOfMonth(feb)), iso(endOfMonth(feb)));
console.log(iso(addDays(feb, 30)));
const eachDay = (from, to) => {
const out = [];
for (let d = from; d <= to; d = addDays(d, 1)) out.push(iso(d));
return out;
};
console.log(eachDay(new Date('2024-02-27T00:00:00Z'), new Date('2024-03-02T00:00:00Z')));
Add a startOfWeek that treats Monday as day 1. Then make eachDay stop after a maximum count so a bad range cannot loop forever.
Exercises
Add days without mutating
Write addUtcDays(date, days) which returns a new Date shifted by that many whole days in UTC. It must leave the input untouched, handle negative values, and cross month and year boundaries correctly (including 29 February in a leap year).
Whole days between two dates
Write daysBetween(from, to) which returns the number of whole calendar days from from to to, counted in UTC and ignoring the time of day. Two instants on the same UTC date give 0, and a to before from gives a negative number. Return NaN if either date is invalid.
Check yourself
- What does this log?
- 1 1 — The month argument is zero based, so
1is February, andgetMonthreturns the same zero based1. The day of month is one based, sogetDate()is1. The date is 1 February 2024. - How do
new Date("2024-03-05")andnew Date("2024-03-05T00:00")differ? - The first is UTC midnight, the second is local midnight — A date-only ISO string is defined to be UTC. Add a time with no offset and the string becomes local. That single inconsistency is behind most "the date shows a day early" bug reports.
- Two Dates hold the same instant. Which comparison is
true? a.getTime() === b.getTime()— Both===and==compare object identity for two objects, andObject.isis stricter still. Compare the numbers withgetTime()or+a === +b. Relational operators such as<do work, because they coerce to numbers first.- You call
d.setUTCDate(40)on 5 March. What happens? drolls over to 9 April, andditself is modified — Setters normalise out-of-range values by rolling into the next month, which is what makessetUTCDate(getUTCDate() + n)a correct way to add days. They also mutate in place and return a timestamp, not a Date, so copy before you set.
Common mistakes
- Passing a one based month to the constructor, giving a date one month late.
- Assuming
new Date("2024-03-05")is local midnight. It is UTC. - Parsing a custom format such as
"05/03/2024"withnew Date, which is engine dependent. - Comparing dates with
===, or checking validity withtypeof. - Mutating a caller's Date with a setter instead of copying first.
- Adding
86400000to cross a daylight saving boundary and losing an hour of wall clock time.
Takeaways
- A Date is one UTC millisecond count; local time is only a rendering of it.
- Months are zero based in the constructor, the getters and the setters, consistently.
- Date-only ISO strings parse as UTC, date-time strings without an offset parse as local.
- Setters mutate and normalise: copy first, then use
setUTCDate(getUTCDate() + n)to add days. - Compare timestamps, never Date objects, and validate with
Number.isNaN(d.getTime()). - Decide per value whether you are storing an instant or a calendar date.