skip to content

What does the JavaScript expression `new Date(2025, 0, 31)` represent, and which of the Date constructor's argument conventions most often cause off-by-one bugs?

level: juniorimportance: must knowfreq 72%

answer

  1. months count from zero
  2. day-of-month counts from one
  3. arguments mean local time
  4. out-of-range values roll over silently
  5. Date.UTC for the UTC reading

basics

~20 s

new Date(2025, 0, 31) is 31 January 2025 at local midnight. The month argument is zero-indexed (0 = January) while day-of-month starts at 1, and multi-argument construction is always read in the host's local time zone.

solid answer

~40 s

The multi-argument form is `new Date(year, monthIndex, day, hours, minutes, seconds, ms)`, and the fields are inconsistent on purpose-of-history: `monthIndex` counts from 0, so 0 is January and 11 is December, while `day` counts from 1. So `new Date(2025, 0, 31)` is 31 January 2025, 00:00 in whatever time zone the machine is set to — this form never means UTC. Out-of-range values do not throw; they roll over, so `new Date(2025, 12, 1)` is 1 January 2026 and `new Date(2025, 0, 0)` is 31 December 2024. Two more traps: a year between 0 and 99 is mapped into the 1900s, and omitted trailing arguments default to 1 for day and 0 for the time fields. When you want the UTC reading, build the timestamp with `Date.UTC(...)` and pass it to `new Date()`.

go deeper

for a junior

Be able to state without hesitating that the month argument starts at 0 while the day starts at 1, and that these fields are read in local time. Know that getMonth() needs +1 before display.

for a middle

Explain that the constructor normalises out-of-range fields rather than validating them, show the last-day-of-month idiom that exploits it, and reach for Date.UTC() when the value must be an absolute instant.

for a senior

Show how a locally-constructed Date leaks the server's time zone into stored data, and describe the validation wrapper you would put around user-entered calendar fields so silent rollover never reaches persistence.

for a principal

Own the rule for the codebase: where calendar fields may be assembled at all, which layer is allowed to touch local-time constructors, and why absolute instants should be built from UTC fields or parsed offsets rather than from machine-local parts.

## What a Date actually is A JavaScript `Date` object stores exactly one thing: a *time value*, a number of milliseconds since the Unix epoch, 1970-01-01T00:00:00Z. Everything else — years, months, hours — is computed on demand from that single number. The constructor's job is to turn whatever you pass into that number. ## The three constructor shapes ```js new Date(); // now new Date(1741996800000); // a time value in milliseconds new Date('2025-03-15'); // parse a string new Date(2025, 0, 31); // calendar fields, LOCAL time ``` The multi-argument form is the one this question is about. Its full signature is `new Date(year, monthIndex, day, hours, minutes, seconds, milliseconds)`; only `year` and `monthIndex` are required, `day` defaults to 1 and the time fields default to 0. ## Zero-indexed months, one-indexed days The month argument is a *month index*: 0 is January, 11 is December. Day-of-month is a real calendar day number starting at 1. There is no principle behind the mismatch — it was copied from an early Java API in 1995 and frozen by the web's backwards-compatibility rules. The practical consequence is that `new Date(2025, 12, 25)` is not Christmas; it is 25 January 2026. The same zero-indexing shows up on the read side: `getMonth()` returns 0–11, so display code needs `getMonth() + 1`, while `getDate()` (day of month) and `getFullYear()` need no adjustment. Note also that `getDate()` is the day of the month and `getDay()` is the day of the week (0 = Sunday) — a second naming trap in the same API. ## It is local time, always The calendar fields you pass are interpreted in the host environment's current time zone. On a machine set to UTC-05:00, `new Date(2025, 0, 31)` holds the time value for 2025-01-31T05:00:00Z. Run the same code on a UTC server and you get a different instant. This is why building dates from parts and then serialising them is a classic source of "the date is one day off in production". To construct from UTC fields instead, use the static `Date.UTC()`, which takes the same argument list but returns a raw time value: ```js const utcMidnight = new Date(Date.UTC(2025, 0, 31)); utcMidnight.toISOString(); // '2025-01-31T00:00:00.000Z' everywhere ``` ## Out-of-range values roll over, they do not throw The specification normalises the fields rather than validating them. `new Date(2025, 0, 32)` is 1 February 2025; `new Date(2025, 0, 0)` is 31 December 2024, because day 0 is "one before the first"; `new Date(2025, -1, 1)` is 1 December 2024. Hours, minutes and seconds roll the same way, so `new Date(2025, 0, 1, 25)` is 2 January at 01:00. This is occasionally useful — `new Date(y, m + 1, 0)` is the well-known idiom for the last day of month `m` — but it also means the constructor will never tell you that user input was nonsense. `new Date(2025, 1, 30)` silently becomes 2 March. If you need validation, check the fields you get back: ```js function makeLocalDate(y, m, d) { const dt = new Date(y, m - 1, d); const ok = dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d; return ok ? dt : null; } ``` ## The two-digit-year rule If `year` is an integer from 0 to 99 it is treated as 1900 + year, so `new Date(99, 0, 1)` is 1 January 1999 and `new Date(5, 0, 1)` is 1905. To construct a real year in the first century you must call `setFullYear()` afterwards. This rule applies only to the multi-argument form, not to `Date.parse` or ISO strings. ## Invalid Date If the resulting time value is `NaN` — or exceeds the representable range of ±8,640,000,000,000,000 ms (about ±273,790 years) — you get an *Invalid Date*: a real Date object whose `getTime()` is `NaN`. It does not throw at construction. `isNaN(d.getTime())` or `Number.isNaN(d.valueOf())` is the standard check; note that `toISOString()` on such an object throws a `RangeError`, which is a common way this surfaces late. ## What to say in an interview Name the mismatch (month from 0, day from 1), say the fields are local, mention that out-of-range values roll over instead of throwing, and reach for `Date.UTC()` the moment the value is meant to be an absolute instant rather than a wall-clock reading on this machine.

  • How would you validate that a user-entered year, month and day is a real calendar date, given that the Date constructor never rejects anything?
    Construct it, then read the fields back and compare: if `getFullYear()`, `getMonth()` and `getDate()` do not match what you passed, the constructor rolled the value over and the input was invalid. Checking `isNaN(d.getTime())` alone is not enough, because 30 February produces a perfectly valid Date object for 2 March.
  • What is the idiomatic way to get the number of days in a given month using the Date constructor?
    Rely on rollover: `new Date(year, month + 1, 0).getDate()` gives the last day of `month`, because day 0 of the next month normalises to the final day of the current one. It handles leap years automatically, since the normalisation uses the real calendar.
  • Why does `getDate()` not return the day of the week, and what does?
    `getDate()` returns the day of the month, 1–31. The day of the week is `getDay()`, returning 0 for Sunday through 6 for Saturday. The near-identical names are a well-known source of bugs; both have `getUTCDate()` and `getUTCDay()` counterparts that read the same instant in UTC.

saying these in an interview costs you the question

  • Thinks new Date(2025, 0, 31) is in December
  • Says months and days are both zero-indexed
  • Assumes the multi-argument constructor means UTC
  • Expects new Date(2025, 1, 30) to throw or be invalid
  • Thinks new Date(99, 0, 1) means the year 99

context