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);        // true

A 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
ConstructorMeansTime zone
new Date()right nown/a
new Date(1709596800000)that exact instantUTC by definition
new Date(2024, 2, 5)5 March 2024, midnightlocal, month is zero based
new Date("2024-03-05")5 March 2024, midnightUTC (date only ISO)
new Date("2024-03-05T00:00")5 March 2024, midnightlocal (no offset given)
new Date("2024-03-05T00:00Z")5 March 2024, midnightUTC (explicit offset)
new Date("5 March 2024")whatever the engine guessesimplementation defined
new Date(other)a copysame 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); } // RangeError

Detecting 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 28

The 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'))); // 2

Comparing and diffing.

now, as a number
Date.now()
copy a date
new Date(d.getTime())
build a UTC instant
new Date(Date.UTC(y, m, day)), month zero based
stored calendar date
d.toISOString().slice(0, 10)
is it valid?
!Number.isNaN(d.getTime())
days in a month
new Date(Date.UTC(y, m + 1, 0)).getUTCDate()
compare
compare getTime() values, never the objects
show a human
toLocaleDateString(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 1 is February, and getMonth returns the same zero based 1. The day of month is one based, so getDate() is 1. The date is 1 February 2024.
How do new Date("2024-03-05") and new 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, and Object.is is stricter still. Compare the numbers with getTime() 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?
d rolls over to 9 April, and d itself is modified — Setters normalise out-of-range values by rolling into the next month, which is what makes setUTCDate(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" with new Date, which is engine dependent.
  • Comparing dates with ===, or checking validity with typeof.
  • Mutating a caller's Date with a setter instead of copying first.
  • Adding 86400000 to 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.