JavaScript's Date object has been broken since 1995. Months start at zero.
Every instance is mutable, so a function can silently change a date you passed in.
Timezone handling is a minefield. Parsing is inconsistent across engines.
We've all worked around it with Moment, date-fns, or Day.js — but as of
Node.js 26 (May 2026) and the ECMAScript 2026 spec, we no longer have to.
The Temporal API is here, built into the language, and it fixes
every single one of those problems. Here's how it actually works and how to start
using it today.
Why Date needed replacing
It's worth understanding why Temporal exists before learning the API, because
the design decisions only make sense once you've felt the pain. The core problems with
Date are:
- Mutability —
date.setMonth(3)changes the object in place. Pass a Date into a function and it can come back different. This creates an entire class of bugs that are hard to trace. - No timezone support — a Date holds a UTC timestamp internally and renders it in the host's local timezone. There is no way to say "3pm in Tokyo" — you get "3pm in whatever timezone the server happens to be in", and the offset math is yours to do by hand.
- Months are 0-indexed —
new Date(2026, 6, 21)is July, not June. Every developer gets bitten by this at least once. - Parsing is unreliable —
Date.parse("2026-07-21")returns different results in different engines and different locales. The spec doesn't fully pin it down, so you can't trust it.
Libraries like Moment.js papered over these issues for years, but they added bundle weight and their own API surfaces. Temporal solves it at the language level, which means no dependency, no tree-shaking worries, and every engine agrees on behavior.
The Temporal type system — pick the right one
The single biggest shift from Date to Temporal is that there isn't one date type anymore. Temporal gives you several, and each one represents a different concept. Choosing the right type is most of the work — after that, the methods are obvious.
Here's the mental model: if you don't need a timezone, use a "Plain" type.
If you do, use ZonedDateTime. And if you need a single point on the universal
timeline (a server log, a database timestamp), use Instant.
PlainDate — dates without timezone baggage
A birthday is July 21st. Not July 21st in UTC, or July 21st at some offset — just July 21st.
That's what Temporal.PlainDate represents: a calendar date with no time and
no timezone attached.
// Create from a string — months are 1-indexed, finally
const birthday = Temporal.PlainDate.from("2026-07-21");
// Or from fields — no more month-minus-one
const deadline = Temporal.PlainDate.from({ year: 2026, month: 12, day: 31 });
// Arithmetic returns a new object (immutable!)
const nextWeek = birthday.add({ days: 7 });
// birthday is still 2026-07-21 — nothing mutated
// Comparison is built in
Temporal.PlainDate.compare(birthday, deadline); // -1 (birthday is earlier)
That immutability isn't a small thing. With Date, I've tracked down bugs where
a sorting function called setDate() and silently changed every date in the
array. Temporal makes that impossible — every arithmetic operation returns a new object.
ZonedDateTime — when the timezone actually matters
If you're scheduling a meeting, sending a notification at a local time, or displaying
"event starts at 2pm your time", you need a timezone. ZonedDateTime is the
type that carries one:
// A meeting at 2:30pm in Harare
const meeting = Temporal.ZonedDateTime.from({
year: 2026, month: 7, day: 21,
hour: 14, minute: 30,
timeZone: "Africa/Harare"
});
// What time is that in London?
const inLondon = meeting.withTimeZone("Europe/London");
console.log(inLondon.toString());
// 2026-07-21T13:30:00+01:00[Europe/London]
// DST is handled automatically — no manual offset math
const winterMeeting = Temporal.ZonedDateTime.from(
"2026-12-21T14:30:00[America/New_York]"
);
// Temporal knows New York is UTC-5 in December, UTC-4 in summer
The critical design decision here is that ZonedDateTime stores the
IANA timezone name (like America/New_York), not a fixed
offset. That means it handles daylight saving time transitions correctly — something that
is essentially impossible to do reliably with Date.
Instant — the universal timestamp
An Instant is a point on the timeline, in UTC, with nanosecond precision.
It's what you want for server logs, database timestamps, and "when did this event
actually happen" questions:
// Right now, as a precise UTC timestamp
const now = Temporal.Now.instant();
// From a string
const serverLog = Temporal.Instant.from("2026-07-21T12:30:00Z");
// Convert to a local time for display
const localTime = serverLog.toZonedDateTimeISO("Africa/Harare");
console.log(localTime.toPlainTime().toString()); // "14:30:00"
The flow is almost always: store as Instant (or its string form), display
as ZonedDateTime in the user's timezone. This is the pattern every timezone
tutorial tells you to follow, but Temporal is the first built-in API that makes it natural
rather than painful.
Duration and arithmetic — no more manual millisecond math
Adding 30 days to a Date meant date.setDate(date.getDate() + 30) — mutating
the object, and hoping the month rollover worked. Temporal makes arithmetic explicit
and safe:
// Durations are their own type
const trialPeriod = Temporal.Duration.from({ days: 14 });
const subscription = Temporal.Duration.from({ months: 1 });
// Add a duration to a date
const signupDate = Temporal.PlainDate.from("2026-07-21");
const trialEnd = signupDate.add(trialPeriod); // 2026-08-04
const renewalDate = signupDate.add(subscription); // 2026-08-21
// Difference between two dates
const daysLeft = Temporal.PlainDate.from("2026-12-31")
.since(signupDate, { largestUnit: "day" });
console.log(daysLeft.toString()); // "P163D" — 163 days
// Duration comparison
const longer = Temporal.Duration.compare(trialPeriod, subscription);
// Works correctly even across month boundaries
The since() and until() methods replace the old "subtract two
timestamps and divide by 86400000" pattern. They return a proper
Duration object that you can inspect, format, and pass around — not a raw
number of milliseconds that you have to decode.
Migrating from Date — the practical path
You don't have to rewrite everything at once. The cleanest migration path I've found is:
- New code uses Temporal. Stop writing
new Date()in any new function. - Boundary conversion. Where old code hands you a Date, convert at
the boundary:
// Legacy Date → Temporal Instant const legacy = new Date(); const instant = legacy.toTemporalInstant(); // Temporal Instant → legacy Date (for old APIs) const backToDate = new Date(instant.epochMilliseconds); - Replace date-fns / Day.js / Moment gradually. Most of what those libraries do — add days, format, compare, parse ISO strings — Temporal handles natively. Each time you touch a file that imports a date library, try switching that function to Temporal.
- Drop the dependency once the import count hits zero.
In Node.js 26, Temporal is available with no flags or polyfills. For browsers, Chrome
and Edge are shipping support in mid-2026 (behind a flag initially), and Firefox and
Safari are actively implementing. In the meantime, the
@js-temporal/polyfill package gives you the full API today — write against
the real Temporal, and remove the polyfill import when native support lands.
The patterns that change
A few common Date patterns and their Temporal replacements, side by side:
- Get today's date:
new Date()→Temporal.Now.plainDateISO() - Parse an ISO string:
new Date("2026-07-21")→Temporal.PlainDate.from("2026-07-21") - Add days:
d.setDate(d.getDate() + 7)→d.add({ days: 7 }) - Compare dates:
d1.getTime() > d2.getTime()→Temporal.PlainDate.compare(d1, d2) - Days between:
(d2 - d1) / 86400000→d1.until(d2, { largestUnit: "day" }).days - Current Unix timestamp:
Date.now()→Temporal.Now.instant().epochMilliseconds
Every one of these is more readable, more explicit about what it returns, and impossible to accidentally mutate. That's the real win — not just nicer syntax, but fewer bugs by design.