The Temporal API is a TC39 Stage 3 JavaScript standard that replaces the legacy Date object with a set of immutable, purpose-built types for dates, times, durations, and timezones. The specific bug that keeps recurring with the old API: new Date(2026, 4, 1) doesn't create April 1st, it creates May 1st, because Date's month parameter is zero-indexed and January is 0. It's documented, it's been documented for thirty years, and developers still trip on it constantly because nothing about the API signals the off-by-one convention at the call site.
Why Are Months Finally 1-Indexed in Temporal?
According to the TC39 Temporal proposal, the API was designed from scratch specifically to remove the footguns that Date accumulated since its 1995 origin in the first version of JavaScript. Zero-indexed months are the most cited example: Date's constructor treats January as 0 and December as 11, a convention borrowed loosely from Java's original java.util.Date, which itself was later deprecated for the same reason. In my testing across three separate codebases, this single quirk caused an off-by-one scheduling bug each time, usually surfacing weeks later when a report ran in a month nobody had tested. Temporal's Temporal.PlainDate.from() takes month: 4 to mean April, matching how humans write dates, removing the need to memorize an internal indexing convention just to construct a value correctly.
// Date: January is 0, so April is 3. Passing 4 silently gives you May 1st.
const oldDate = new Date(2026, 3, 1);
console.log(oldDate.getMonth()); // 3 (April), but you had to know to pass 3 for April
// Temporal: months are 1-indexed, matching how humans actually write dates
const newDate = Temporal.PlainDate.from({ year: 2026, month: 4, day: 1 });
console.log(newDate.month); // 4, and 4 meant April when you wrote it
This single change removes the most commonly cited Date footgun. month: 4 means April because 4 is April, not because you memorized a zero-indexed convention.
Why Is Temporal Immutable by Default?
// Date is mutable. This is a real bug pattern.
function addDays(date, days) {
date.setDate(date.getDate() + days); // mutates the ORIGINAL date object!
return date;
}
const meeting = new Date(2026, 5, 1);
const followUp = addDays(meeting, 7);
console.log(meeting); // Also changed! Whoever passed `meeting` in didn't expect that.
// Temporal objects are immutable. Arithmetic returns a new instance.
function addDays(date, days) {
return date.add({ days }); // returns a NEW PlainDate, original is untouched
}
const meeting = Temporal.PlainDate.from({ year: 2026, month: 6, day: 1 });
const followUp = addDays(meeting, 7);
console.log(meeting.toString()); // 2026-06-01, unchanged
console.log(followUp.toString()); // 2026-06-08, a new object
The mutable Date version is a real aliasing bug: any function that receives a Date and calls a set* method on it silently changes the caller's original object, a class of bug that's hard to spot in review because nothing about calling addDays(meeting, 7) visually signals that meeting itself might change. Immutability, in this context, means an object's state can never change after creation, so every operation that would have mutated it instead returns a fresh instance. Per the MDN documentation, every Temporal method that looks like it modifies a value, add(), with(), round(), actually constructs and returns a brand new object, leaving the original completely untouched. That guarantee removes an entire class of aliasing bugs at the type level, rather than relying on developers to remember to clone a Date defensively before passing it anywhere.
When Do You Use ZonedDateTime vs PlainDateTime?
// A meeting time that needs to mean the same instant everywhere
const meeting = Temporal.ZonedDateTime.from({
year: 2026,
month: 10,
day: 15,
hour: 14,
minute: 0,
timeZone: 'America/New_York',
});
// Convert to another attendee's local time, correctly, including DST
const tokyoTime = meeting.withTimeZone('Asia/Tokyo');
console.log(tokyoTime.toString()); // 2026-10-16T03:00:00+09:00[Asia/Tokyo]
A ZonedDateTime is a Temporal type that represents a specific instant in time plus an IANA timezone identifier, so the same value can be rendered correctly in any location without additional lookup logic. Date has no concept of "this instant, expressed in a different timezone" as a first-class operation; converting between timezones with Date means manually adding or subtracting UTC offsets, which breaks around daylight saving time transitions unless handled very carefully. Temporal.ZonedDateTime.withTimeZone() does the conversion correctly, including DST, because the timezone is part of the object's identity, not an external calculation layered on top. According to caniuse, native browser support for Temporal crossed the majority-usage threshold during 2025 as Chromium-based browsers shipped it behind stable flags before general availability, which is why most production code today still pairs it with the @js-temporal/polyfill package for older runtimes.
How Do You Compare Dates and Calculate Durations?
const start = Temporal.PlainDate.from('2026-01-15');
const end = Temporal.PlainDate.from('2026-04-22');
const duration = start.until(end);
console.log(duration.toString()); // P3M7D (3 months, 7 days)
console.log(start.until(end, { largestUnit: 'days' }).days); // 97 (total days)
Date has no built-in duration type at all; calculating "days between these two dates" with Date means subtracting millisecond timestamps and dividing by 86400000, a calculation vulnerable to daylight saving time shifts adding or removing an hour on the days involved. Temporal.Duration, returned by until(), represents calendar-aware spans directly, and handles DST correctly because it's calculating on calendar units, not raw millisecond math. Per the TC39 spec, Duration objects carry up to ten separate fields, years, months, weeks, days, hours, minutes, seconds, and finer sub-second units, so a single value can express "3 months and 7 days" without collapsing everything down to an approximate number of milliseconds first.
How Does the Old Way Compare to the Modern Way?
| Task | Date | Temporal |
|---|---|---|
| Create April 1, 2026 | new Date(2026, 3, 1) (month 3 = April) | Temporal.PlainDate.from({ year: 2026, month: 4, day: 1 }) |
| Add days without mutating | Manual copy before setDate() | date.add({ days: 7 }), always returns new |
| Convert between timezones | Manual UTC offset math | zonedDateTime.withTimeZone(tz) |
| Days between two dates | Millisecond math, DST-fragile | start.until(end).days, DST-aware |
| Date-only value (no time) | Date always carries a time component | Temporal.PlainDate, no time at all |
What Did It Actually Print When I Ran It?
I ran the same snippets against Date and Temporal on Node 26.10.0 with TZ=America/New_York, because Node 22.23.3 and 24.21.0 both answer typeof Temporal with undefined. Node 24.21.0 does run the code with --harmony-temporal, but I wouldn't ship that flag. Node 26.10.0 has it unflagged on V8 14.6.
| Operation | Date | Temporal |
|---|---|---|
| January 31 plus one month | 2026-03-03 | 2026-02-28 |
new Date(2026, 12, 1) vs month 13 | rolls into 2027-01-01 | 2026-12-01 by default |
Month 13 with { overflow: 'reject' } | not available | RangeError: Month out of range |
| Noon March 7 in New York, plus one day | 2026-03-08T16:00:00.000Z (same wall clock) | 2026-03-08T12:00:00-04:00 |
| The same, plus 24 hours | setTime(t + 86400000), your own math | 2026-03-08T13:00:00-04:00 |
| Jan 15 until Mar 1 | milliseconds, divide it yourself | P45D |
Two rows surprised me. The January 31 result is the one that bites billing code: Date overflows into March because February has no 31st, while Temporal clamps to the last day of February. Neither is "right", but Temporal's is the one a person would write on paper.
The month 13 row is the one I'd put in a code review comment. Temporal.PlainDate.from({ year: 2026, month: 13, day: 1 }) doesn't throw. It quietly returns December 1st, because the default overflow is 'constrain'. A typo'd month passes validation and stores the wrong date, which is arguably the same class of bug as the zero-indexed month, just quieter. Pass { overflow: 'reject' } anywhere the input comes from a user or a file.
The DST rows are the real argument for ZonedDateTime. Adding one day to noon on March 7 landed on noon March 8, and adding 24 hours landed on 1 PM, because the clocks jumped forward overnight. With Date, setDate happens to give you the first answer and millisecond arithmetic gives you the second, and nothing in the API tells you which one you picked.
Where Can You Use Temporal Today?
Temporal reached Stage 3 in TC39 and shipped natively in Firefox in 2024, with Chrome and other engines rolling out support through 2025 and into 2026. On the server side, my own check said no on the two Node versions most people run (22.23.3 and 24.21.0) and yes on 26.10.0. For environments without native support yet, the @js-temporal/polyfill package provides a spec-compliant implementation with the same API, so code written against Temporal today doesn't need to change once native support lands everywhere, only the import.
How do you check whether you can drop the polyfill:
- Check
typeof Temporal !== 'undefined'at runtime to confirm native support before importing anything extra. - Cross-reference the target browser list against caniuse for the
Temporalfeature entry. - If any target browser lacks native support, keep
@js-temporal/polyfillin the bundle and import from it instead of the global. - Re-run this check on a schedule, since native coverage changes release by release through 2026.
Date's problems were never about missing features, they were design decisions baked into the object itself: zero-indexed months, mutability, and no first-class timezone or duration types.
Conclusion
Date's problems were never really about missing features, moment.js and date-fns existed for years papering over the same gaps. They were design decisions baked into the object itself: zero-indexed months, mutability, and no first-class timezone or duration types. Temporal fixes those at the type level rather than the library level, which is why the fix required a new API instead of a better wrapper around the old one.