The specific bug that keeps recurring: new Date(2026, 3, 1) doesn't create April 1st, it creates May 1st, because Date's month parameter is zero-indexed, 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.
TL;DR: The Temporal API replaces
Datewith a set of immutable, purpose-specific types:Temporal.PlainDate(calendar date, no time),Temporal.PlainTime(time, no date),Temporal.ZonedDateTime(a real instant with explicit timezone), and more. Months are 1-indexed. Objects are immutable, arithmetic returns a new instance instead of mutating in place. Timezone handling is explicit rather than implicit, which eliminates an entire category of bugsDatemade easy to write by accident.
Months Are Finally 1-Indexed
// Date: January is 0. This creates May 1st, not April 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.
Immutability: No More Accidental Mutation
// 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.
Explicit Timezones: 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]
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/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.
Comparing and Calculating 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.
Old Way vs 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 |
Current Availability
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. 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.
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.