Skip to main content
JavaScript · lesson 15 of 32
JavaScript intermediate Lesson 15 of 32

Dates and Times in JavaScript: Date Pitfalls and Temporal

Date's setMonth silently rolls a short month into the next one. See Temporal.PlainDate clamp the same case instead, a DST change split +1 day from +24 hours, until() measure a real gap, and overflow: "reject" throw instead of guessing.

By SkillByExample · Published

javascript dates temporal dst

Date never refuses an out-of-range value — it recalculates around it, which is exactly the problem. Node 26 (Active LTS since October 2026) adds Temporal, a second date and time API designed to make that kind of silent recalculation an explicit choice instead of a surprise. Temporal is undefined on Node 24.18.1, the version every other lesson in this topic uses, so every example below that touches Temporal runs in Docker’s node:26.13 image instead, with the container’s time zone pinned explicitly on each docker run line.

Date’s setMonth Rolls Over Instead of Clamping

setMonth mutates a Date in place: pick the month you want, and Date recomputes the rest of the fields around it — including the day of month, which it does not clamp to the new month’s own length.

const jan31 = new Date(2026, 0, 31); // January 31, 2026
console.log("before:", jan31.toDateString());

jan31.setMonth(1); // "set it to February"
console.log("after :", jan31.toDateString());
console.log("month :", jan31.getMonth(), "date:", jan31.getDate());
$ node rollover.js
before: Sat Jan 31 2026
after : Tue Mar 03 2026
month : 2 date: 3

February 2026 has 28 days, not 31. setMonth(1) doesn’t refuse the day it can’t fit — it moves to month index 1 (February), finds day 31 doesn’t exist there, and carries the extra 3 days forward into March instead. getMonth() confirms it: 2 is March (months are zero-indexed), not 1. Nothing threw, and nothing in the return value signals that anything unusual happened.

Temporal.PlainDate Clamps the Same Overflow Instead

Temporal.PlainDate represents a calendar date with no time or time zone attached — exactly the “just a date” Date is often misused for. Its .add() method faces the identical overflow case setMonth did above, and handles it differently.

const jan31 = Temporal.PlainDate.from("2026-01-31");
console.log("before:", jan31.toString());

const added = jan31.add({ months: 1 });
console.log("after :", added.toString());
console.log("month :", added.month, "day:", added.day);
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node plaindate.mjs
before: 2026-01-31
after : 2026-02-28
month : 2 day: 28

Same starting date, same “add one month” request, a different result shape: added.day is 28, clamped to the last real day of February, and added.month stays 2 — the month setMonth overflowed out of a moment ago. This is Temporal’s default overflow behavior, called "constrain". It still doesn’t throw, but it never lands on a month you didn’t ask for either.

+1 Day Across a DST Change Isn’t +24 Hours

Temporal.ZonedDateTime ties a date and time to both a UTC offset and an IANA time zone name, so it knows exactly when that zone’s offset itself changes — a daylight-saving transition. America/New_York springs forward on March 8, 2026, when clocks jump from 2:00 AM straight to 3:00 AM.

const start = Temporal.ZonedDateTime.from("2026-03-07T12:00:00-05:00[America/New_York]");
console.log("start  :", start.toString());

const plusDay = start.add({ days: 1 });
console.log("+1 day :", plusDay.toString());

const plusHours = start.add({ hours: 24 });
console.log("+24 h  :", plusHours.toString());
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=America/New_York -v "$(pwd)":/w -w /w node:26.13 node dst.mjs
start  : 2026-03-07T12:00:00-05:00[America/New_York]
+1 day : 2026-03-08T12:00:00-04:00[America/New_York]
+24 h  : 2026-03-08T13:00:00-04:00[America/New_York]

start is noon on March 7th, still on standard time (-05:00). Adding { days: 1 } adds one calendar day and keeps the wall clock reading 12:00 — Temporal resolves that against whichever offset March 8th actually has, -04:00, because the zone has sprung forward by then. Adding { hours: 24 } instead adds a fixed 24 hours of real elapsed time, and that lands on 13:00, not 12:00, because the day it crosses only had 23 real hours in it — the clock-jump at 2 AM skipped straight to 3 AM, so 24 real hours past noon lands an hour later on the wall clock than a calendar day did. Date isn’t missing this distinction because it can’t do calendar arithmetic — d.setDate(d.getDate() + 1) on a Date holding March 7th noon keeps the wall clock at noon on March 8th, the same “+1 day” behavior Temporal showed above (the setMonth rollover example earlier in this lesson is the same kind of calendar-aware local-time method). What Date actually lacks is any notion of a specific IANA zone independent of the host it’s running on: it only ever has two zones available, whatever the OS’s local zone happens to be and UTC, so there’s no way to ask it “what’s the offset for America/New_York on this date” the way Temporal.ZonedDateTime just did — the comparison above only exists because Temporal can attach a named zone to a value instead of inheriting one from the machine.

Measuring the Gap Between Two Dates with until()

.until() computes the span from one Temporal date to another and returns a Temporal.Duration object, rather than a raw count of milliseconds. The ISO 8601 strings below only appear because the example calls .toString() on that object explicitly — a template literal (`${duration}`) would call it implicitly instead.

const start = Temporal.PlainDate.from("2025-12-01"); // a project's start date
const end = Temporal.PlainDate.from("2026-03-09"); // today

console.log("default        :", start.until(end).toString());
console.log("largestUnit year:", start.until(end, { largestUnit: "year" }).toString());
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node until.mjs
default        : P98D
largestUnit year: P3M8D

Left to its own defaults, .until() reduces the whole 98-day gap to a single unit — P98D, 98 days, its largest unit being days. Passing { largestUnit: "year" } tells it to use the coarsest units available first, so the same 98 days comes back broken into calendar units a person would actually say out loud: P3M8D, 3 months and 8 days. Neither call touched a millisecond timestamp anywhere in the calculation.

Rejecting an Invalid Result Instead of Guessing: overflow: “reject”

Every Temporal method that can produce an out-of-range date — .add(), .from(), .with() — accepts an overflow option. "constrain", the default seen above, clamps. "reject" throws instead.

const jan31 = Temporal.PlainDate.from("2026-01-31");

const constrained = jan31.add({ months: 1 }); // overflow: "constrain" (the default)
console.log("constrain:", constrained.toString());

try {
  jan31.add({ months: 1 }, { overflow: "reject" });
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node overflow.mjs
constrain: 2026-02-28
RangeError: Temporal error: not a valid ISO date.

The first call repeats the clamp from two sections ago — 2026-02-28, no error. The second call asks for the identical result but with { overflow: "reject" }, and Temporal throws a RangeError rather than returning a date you didn’t ask for. This is the setting to reach for whenever a clamped result would be a bug rather than a convenience — a billing system silently moving an invoice’s due date from the 31st to the 28th is exactly the kind of mistake "constrain"’s silence enables, and "reject" turns it into an exception you have to handle on purpose.

Formatting a Temporal Value for Humans

Temporal objects have their own .toLocaleString(), built on the same Intl machinery Date uses, so locale-aware formatting doesn’t need a separate library.

const meetingDay = Temporal.PlainDate.from("2026-03-08");

console.log(meetingDay.toLocaleString("en-US", { dateStyle: "long" }));
console.log(meetingDay.toLocaleString("en-GB", { dateStyle: "long" }));
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node locale.mjs
March 8, 2026
8 March 2026

Same PlainDate, same dateStyle: "long" options object, two different locale tags — en-US writes the month first, en-GB writes the day first. PlainDate carries no time zone, so there’s nothing for the locale formatting to get wrong across a DST boundary the way a raw timestamp could.

Practice

  1. A different short month. Change the setMonth example to start from January 30th instead of the 31st, and set it to February. Predict the rolled-over date before running it.
Solution
const jan30 = new Date(2026, 0, 30); // January 30, 2026
console.log("before:", jan30.toDateString());

jan30.setMonth(1); // "set it to February"
console.log("after :", jan30.toDateString());
$ node p1.js
before: Fri Jan 30 2026
after : Mon Mar 02 2026

February 2026 still only has 28 days, so day 30 overflows by 2 this time instead of 3 — one day less of rollover than the lesson’s January 31st example, landing on March 2nd instead of March 3rd.

  1. A month add that needs no clamping. Add one month to December 31st, 2026 with Temporal.PlainDate, and confirm it lands on a real date with no overflow at all.
Solution
const dec31 = Temporal.PlainDate.from("2026-12-31");
console.log(dec31.add({ months: 1 }).toString());
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node p2.mjs
2027-01-31

January has 31 days too, so there’s nothing to clamp — the date simply rolls into the next year, 2027-01-31, exactly the result Date.setMonth would also give for this particular case. The overflow difference only shows up when the target month is shorter than the source day.

  1. The other DST transition. America/New_York falls back on November 1st, 2026, when clocks move from 2:00 AM back to 1:00 AM — the zone gains an hour instead of losing one. Repeat the +1 day vs +24 hours comparison starting from noon on October 31st, and predict which result now comes out earlier in the day.
Solution
const start = Temporal.ZonedDateTime.from("2026-10-31T12:00:00-04:00[America/New_York]");
console.log("start  :", start.toString());

const plusDay = start.add({ days: 1 });
console.log("+1 day :", plusDay.toString());

const plusHours = start.add({ hours: 24 });
console.log("+24 h  :", plusHours.toString());
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=America/New_York -v "$(pwd)":/w -w /w node:26.13 node p3.mjs
start  : 2026-10-31T12:00:00-04:00[America/New_York]
+1 day : 2026-11-01T12:00:00-05:00[America/New_York]
+24 h  : 2026-11-01T11:00:00-05:00[America/New_York]

The pattern flips. +1 day still keeps noon on the wall clock. +24 h now lands at 11:00 AM, an hour earlier, because the day it crosses has 25 real hours in it — the 1:00-to-2:00 AM hour happened twice — so 24 real hours past noon falls short of the next noon by exactly that extra hour.

  1. A negative duration. Call .until() with the start and end dates swapped from the lesson’s example, still with { largestUnit: "year" }. Confirm the sign of the result.
Solution
const start = Temporal.PlainDate.from("2025-12-01");
const end = Temporal.PlainDate.from("2026-03-09");

console.log(end.until(start, { largestUnit: "year" }).toString());
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node p4.mjs
-P3M8D

Calling .until() from the later date to the earlier one just negates the result — a leading - on the same duration, -P3M8D instead of P3M8D, rather than a different magnitude.

  1. Reject an invalid date built from parts. Use Temporal.PlainDate.from() with an object ({ year, month, day }) instead of a string, asking for February 30th, 2026, once with the default overflow and once with { overflow: "reject" }.
Solution
console.log("constrain:", Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 }).toString());

try {
  Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 }, { overflow: "reject" });
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}
$ MSYS_NO_PATHCONV=1 docker run --rm -e TZ=UTC -v "$(pwd)":/w -w /w node:26.13 node p5.mjs
constrain: 2026-02-28
RangeError: Temporal error: day value is not in a valid range.

Building a date from { year, month, day } parts hits the same overflow option as .add() did in the lesson — the default clamps day 30 down to 28, and "reject" throws instead. The error message differs slightly from .add()’s (day value is not in a valid range versus not a valid ISO date), because the two code paths validate the input differently, but both are RangeError.

Frequently Asked Questions

Why does Date.setMonth change the year or skip into a different month than I asked for?
setMonth sets the month to the index you pass, then normalizes the day of month against whatever that target month actually has, carrying any excess forward. Setting January 31st to month 1 (February) doesn't clamp to February 28th; February has no 31st day, so the extra 3 days roll over and land the result in March. Date never throws on an out-of-range value — it always produces some date, just not necessarily the one you meant.
Is Temporal available in Node.js yet?
Yes. Node 26 was released in April 2026 and was promoted to the Active LTS line in October 2026; either way, Temporal is a global object on it, not an import. Node 24 and earlier have no Temporal global at all; referencing it throws ReferenceError: Temporal is not defined. Browsers are still adding support path by path, so a web app targeting older browsers still needs Date or a library until Temporal ships everywhere.
What's the actual difference between adding 1 day and adding 24 hours to a Temporal.ZonedDateTime?
Adding 1 day adds a calendar day and keeps the same wall-clock time, resolving whatever UTC offset that day actually has — across a daylight-saving change, that's not the same moment 24 hours later. Adding 24 hours adds a fixed duration of real elapsed time instead, which lands on a different wall-clock time whenever the zone's offset changed in between, because a 24-hour span crosses a day that was only 23 or 25 hours long.
What does Temporal's until() method return?
A Temporal.Duration object, not a string. Calling .toString() on it (or interpolating it into a template literal, which calls toString implicitly) produces an ISO 8601 duration string such as P3M8D for 3 months and 8 days. The default call reduces the whole gap to one unit (days), so you pass { largestUnit: "year" } (or "month") to get it broken into coarser units first, the way a human would read it.
What's the difference between overflow: "constrain" and overflow: "reject" in Temporal?
They're the two ways Temporal can handle an operation whose result isn't a real calendar date, such as adding a month to January 31st and landing on "February 31st." "constrain" is the default: it clamps the invalid part (day 31) down to the nearest valid value (28) and returns that date silently. "reject" throws a RangeError instead, which is the safer choice whenever silently guessing a different date than the one you asked for would be a bug.