Skip to content

The Temporal API: Fixing JavaScript Dates for Good

Why Temporal Replaces the Broken Date Object

The Temporal API fixes JavaScript's Date object at the design level: immutability, real timezone handling, and no more off-by-one month bugs.

· · 4 min read

Quick Take

I've fixed the same class of date bug in three different codebases: a month that was one off because Date's constructor counts January as 0. Temporal doesn't have that footgun, because it was designed after we knew where Date went wrong.

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 Date with 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 bugs Date made 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

TaskDateTemporal
Create April 1, 2026new Date(2026, 3, 1) (month 3 = April)Temporal.PlainDate.from({ year: 2026, month: 4, day: 1 })
Add days without mutatingManual copy before setDate()date.add({ days: 7 }), always returns new
Convert between timezonesManual UTC offset mathzonedDateTime.withTimeZone(tz)
Days between two datesMillisecond math, DST-fragilestart.until(end).days, DST-aware
Date-only value (no time)Date always carries a time componentTemporal.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.

Frequently Asked Questions

What was actually wrong with the original JavaScript Date object?
Several design decisions that made sense in 1995 but caused decades of bugs: months are zero-indexed (January is 0, December is 11), Date objects are mutable (calling setMonth() on a date changes it in place, a common source of aliasing bugs), there's no built-in distinction between a date, a time, and a timezone-aware instant, and timezone handling relies on the host environment's local timezone by default in ways that are easy to get wrong silently.
Do I need to replace every Date in my codebase with Temporal right away?
No. Temporal and Date coexist, and a full migration isn't necessary or even advisable for a large codebase overnight. The practical path is using Temporal for new code, especially anything timezone-sensitive or doing date arithmetic, while leaving stable, working Date-based code alone unless it's actively being modified or has a known bug Temporal would fix directly.
What Temporal type should I use for storing a date of birth versus a scheduled meeting time?
A date of birth is a Temporal.PlainDate, a calendar date with no time or timezone component, since a birthday doesn't have a time or timezone associated with it meaningfully. A scheduled meeting needs a Temporal.ZonedDateTime, because 'the meeting is at 2pm' is meaningless without knowing which timezone that 2pm refers to, and the same instant means a different wall-clock time depending on where each attendee is.