JavaScript · lesson 15 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1Dates 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
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
- A different short month. Change the
setMonthexample 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.
- 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.
- The other DST transition.
America/New_Yorkfalls 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 dayvs+24 hourscomparison 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.
- 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.
- 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.