The Temporal API
Try this in a console right now:
new Date("2026-03-14"); // midnight UTC
new Date("2026-03-14 00:00"); // midnight in YOUR local zone
Same-looking string, two different moments in time, and nothing in the syntax warns you. That is the kind of quiet trap that has shipped countless off-by-one-day bugs. Date is the oldest corner of JavaScript, copied wholesale from Java in 1995 and frozen ever since. It has real problems, and for thirty years the answer was “reach for a library.”
See the gap for yourself. Both strings look like “March 14th,” but the plain Date parser reads one as UTC and one as your local zone — the difference is exactly your machine’s offset from UTC.
Temporal is the fix — a whole new date-and-time namespace, designed from scratch, that lives right next to Math and JSON in the standard library. This page is your tour of it: what the types are, how they fit together, and when you can actually use them.
Why Date needs replacing
Before the cure, be honest about the disease. Four things make Date genuinely hard to use correctly.
It’s mutable. Methods like setMonth change the object in place. Pass a Date to a function and it can silently mutate under you.
let d = new Date(2026, 0, 31);
d.setMonth(1); // "set to February"
d.getDate(); // 3 — not the 31st!
January 31st plus one month lands on February 31st, which doesn’t exist, so Date rolls it forward to March 3rd. No error, just a wrong answer three fields away from where you looked. Watch the same object change under you:
Months count from zero. new Date(2026, 0, 1) is January. Days, years, and hours count from their natural start; months alone are off by one. Everyone gets bitten once.
There is no time-zone type. A Date is one UTC timestamp plus the machine’s local zone. You cannot hold “3pm in Tokyo” as a value. You cannot do math in a named zone. Anything time-zone-aware meant a library.
Parsing is a minefield — as the two-line example up top showed. The exact rules differ across the format you pass and even across engines.
The mental model: exact time vs wall-clock
Temporal’s power comes from one distinction Date never made. There are two fundamentally different things people mean by “a time,” and Temporal gives each its own type.
Exact time is a precise point on the universal timeline — the same instant for everyone on Earth, regardless of where they stand. A server log entry happens at an exact time.
Wall-clock time is what a clock on the wall or a page in a calendar reads. “9:00 AM on March 14th” is a wall-clock value. It is not a point on the timeline until you say whose wall — 9am in Berlin and 9am in New York are different exact moments.
Once you internalize this split, most of Temporal’s design falls out of it. Let’s meet the types.
Temporal.Now — reading the current time
Temporal.Now is a small namespace of functions that answer “what time is it?” — but it makes you say in what form.
Temporal.Now.instant(); // exact time, nanosecond precision
Temporal.Now.zonedDateTimeISO(); // full ZonedDateTime in the system zone
Temporal.Now.plainDateISO(); // today's date, no zone
Temporal.Now.plainTimeISO(); // current wall time, no zone
Temporal.Now.timeZoneId(); // e.g. "Europe/Berlin"
The ISO suffix means “using the ISO 8601 calendar” (the ordinary Gregorian calendar most apps want). Each of these also accepts an explicit time zone:
Temporal.Now.zonedDateTimeISO("Asia/Tokyo");
Temporal.Now.plainDateISO("America/New_York");
Notice there’s no bare Temporal.Now.zonedDateTime() without ISO unless you pass a calendar — Temporal refuses to guess.
Instant — a bare point in time
Temporal.Instant is the simplest exact-time type: a count of nanoseconds since the Unix epoch, with no calendar and no zone attached. It’s the modern equivalent of a Date’s internal timestamp, but with a thousand times finer resolution.
let i = Temporal.Instant.from("2026-07-08T14:30:00Z");
i.epochMilliseconds; // 1783866600000
i.epochNanoseconds; // 1783866600000000000n (a BigInt)
You build one from an ISO string that ends in a UTC designator (Z or an offset like +09:00), or from an epoch number. An Instant alone can’t tell you the hour or the day — for that it needs a zone, which turns it into a ZonedDateTime.
i.toZonedDateTimeISO("Europe/Paris"); // now it has a wall-clock reading
ZonedDateTime — the complete type
Temporal.ZonedDateTime is the one you’ll reach for most when a real moment matters. It bundles three things: an exact instant, an IANA time zone identifier, and a calendar. Because it knows the zone, it can do time math that respects daylight saving, and because it knows the calendar it can report the day, month, and year correctly.
let meeting = Temporal.ZonedDateTime.from(
"2026-07-08T09:00:00[Europe/Berlin]"
);
meeting.hour; // 9
meeting.dayOfWeek; // 3 (Wednesday; ISO weeks start Monday)
meeting.offset; // "+02:00" (Berlin is on DST in July)
meeting.timeZoneId; // "Europe/Berlin"
meeting.toString(); // "2026-07-08T09:00:00+02:00[Europe/Berlin]"
That bracketed string is the RFC 9557 format — an ISO 8601 date-time with the zone name appended in square brackets. It is unambiguous and round-trips perfectly, which is the whole point: serialize a ZonedDateTime, read it back anywhere, get the exact same moment and wall reading.
You don’t need Temporal to feel this idea — the built-in Intl.DateTimeFormat already reads one exact instant through any IANA zone. Edit the UTC instant below and watch four cities disagree about the wall clock while naming the same moment:
The Plain types — wall-clock without a zone
Sometimes a zone is exactly what you don’t want. A store opens at 09:00 in every branch’s own local time. A birthday is the same calendar day everywhere. For these, Temporal gives you plain types that deliberately carry no time zone.
PlainDate— a calendar date: year, month, day.2026-07-08.PlainTime— a wall time: hour, minute, second and below.09:00:00.PlainDateTime— both together, still no zone.2026-07-08T09:00.PlainYearMonth— a month in a year, no day. Good for “the June 2026 invoice.”PlainMonthDay— a day in a year, no year. Good for recurring dates like12-25.
let date = Temporal.PlainDate.from("2026-07-08");
date.year; // 2026
date.month; // 7 — one-indexed! July is 7, not 6
date.day; // 8
date.dayOfWeek; // 3
date.daysInMonth; // 31
Read that comment twice: months are one-based in Temporal. July is 7. The single most infamous Date wart is gone.
You can build these from objects too, which reads far better than positional arguments:
Temporal.PlainDate.from({ year: 2026, month: 7, day: 8 });
Temporal.PlainTime.from({ hour: 9, minute: 30 });
Temporal.PlainYearMonth.from({ year: 2026, month: 6 });
A PlainDate has no notion of “when” in real time — to pin it to the timeline you combine it with a zone, and only then does it become a ZonedDateTime:
let date = Temporal.PlainDate.from("2026-07-08");
date.toZonedDateTime({
timeZone: "Europe/Berlin",
plainTime: "09:00",
});
// 2026-07-08T09:00:00+02:00[Europe/Berlin]
Immutability — every operation returns a new value
This is the rule that makes Temporal safe to pass around: nothing mutates. There is no setMonth. Every method that changes something hands you a brand-new object and leaves the original untouched.
let start = Temporal.PlainDate.from("2026-07-08");
let later = start.add({ days: 30 });
start.toString(); // "2026-07-08" — unchanged
later.toString(); // "2026-08-07" — a new value
Immutability sounds academic until you’ve debugged a shared-mutable-Date bug at 2am. With Temporal you can hand a value to any function and know it comes back exactly as it left.
Arithmetic — add, subtract, until, since
Four methods cover almost all date math. Two move a point; two measure the gap between points.
let d = Temporal.PlainDate.from("2026-07-08");
d.add({ months: 1, days: 3 }); // 2026-08-11
d.subtract({ weeks: 2 }); // 2026-06-24
add and subtract take a duration-shaped object (or a Temporal.Duration, or an ISO duration string). until and since go the other way — give them two points and they return the Duration between them.
let a = Temporal.PlainDate.from("2026-01-01");
let b = Temporal.PlainDate.from("2026-07-08");
a.until(b); // P188D (188 days, the default unit)
a.until(b, { largestUnit: "months" }); // P6M7D
a.until(b) reads as “from a until b.” b.since(a) is the same span. By default the result is expressed in days; pass largestUnit to get months, years, or hours instead.
with() — changing one field
Because you can’t mutate, “set the year to 2027” is spelled as “give me a copy with the year changed.” That’s with:
let d = Temporal.PlainDate.from("2026-07-08");
d.with({ year: 2027 }); // 2027-07-08
d.with({ day: 1 }); // 2026-07-01 ("first of this month")
d.with({ month: 12, day: 25 }); // 2026-12-25
with only touches the fields you name and returns a new value. It’s the immutable answer to every setX method Date had — and it works on all Temporal types, including times and year-months.
Duration — a length of time
Temporal.Duration is a first-class value for “how much time,” independent of any start point. It carries every field from years down to nanoseconds.
let dur = Temporal.Duration.from({ hours: 2, minutes: 30 });
dur.total({ unit: "minutes" }); // 150
dur.add({ minutes: 45 }); // PT3H15M
dur.negated(); // -PT2H30M (points backward)
The catch worth remembering: years, months, and weeks have no fixed length in real time (a month can be 28–31 days; a “day” across a DST boundary can be 23 or 25 hours). So converting a duration that contains those units into a smaller unit isn’t well-defined on its own. You must anchor it to a starting point with relativeTo:
let d = Temporal.Duration.from({ months: 1 });
d.total({ unit: "days" });
// RangeError: needs a relativeTo — which month?
d.total({ unit: "days", relativeTo: "2026-02-01" }); // 28
d.total({ unit: "days", relativeTo: "2026-07-01" }); // 31
Rounding
round snaps a value to a unit. On a duration it also balances the fields.
let t = Temporal.PlainTime.from("14:47:32");
t.round({ smallestUnit: "hour" }); // 15:00:00
t.round({ smallestUnit: "minute", roundingMode: "floor" }); // 14:47:00
let dur = Temporal.Duration.from({ minutes: 130 });
dur.round({ largestUnit: "hours" }); // PT2H10M
roundingMode accepts the familiar set — "halfExpand" (the default, round-half-up), "ceil", "floor", "trunc", and a few more. smallestUnit sets the precision floor; largestUnit (on durations) sets how the result is re-expressed.
Comparing values
Never compare Temporal objects with <, >, or === — they’re objects, so those check identity or coerce to strings. Use the tools each type provides:
let a = Temporal.PlainDate.from("2026-07-08");
let b = Temporal.PlainDate.from("2026-12-25");
a.equals(b); // false
Temporal.PlainDate.compare(a, b); // -1 (a is before b)
compare returns -1, 0, or 1, which is exactly the shape Array.prototype.sort wants:
dates.sort(Temporal.PlainDate.compare);
Talking to legacy Date and ISO strings
You’ll be living in a mixed world for years, so conversions matter. Date grew one new method to bridge across: toTemporalInstant.
let legacy = new Date();
let instant = legacy.toTemporalInstant(); // Date → Instant
// the other direction
let back = new Date(instant.epochMilliseconds); // Instant → Date
From an Instant you can branch out to whatever you need by supplying a zone:
instant.toZonedDateTimeISO("Europe/Berlin"); // full zoned value
instant.toZonedDateTimeISO("UTC").toPlainDate();
For serialization, every Temporal type has a stable toString (and toJSON, so JSON.stringify just works) that emits an ISO 8601 / RFC 9557 string. Parsing back is the matching from. Round-trips are lossless — a stark contrast to Date’s parsing lottery.
JSON.stringify({ when: Temporal.PlainDate.from("2026-07-08") });
// '{"when":"2026-07-08"}'
A real example: DST-safe scheduling
Here’s where ZonedDateTime earns its keep. Schedule a daily 9am alarm and roll it forward a day across a spring-forward boundary:
let alarm = Temporal.ZonedDateTime.from(
"2026-03-08T09:00:00[America/New_York]"
);
// the US "spring forward" happens the night of Mar 8, 2026
let tomorrow = alarm.add({ days: 1 });
tomorrow.toString();
// "2026-03-09T09:00:00-04:00[America/New_York]" — still 9am
Adding one day keeps the wall-clock reading at 9am even though only 23 real hours elapsed — because that’s what “same time tomorrow” means to a human. If instead you add 24 hours, Temporal does real-elapsed-time math and you land at 10am:
alarm.add({ hours: 24 }).toString();
// "2026-03-09T10:00:00-04:00[America/New_York]"
Try expressing that correctly with Date. You can’t, not without a time-zone library and real care. Temporal gets it right because the zone is part of the value.
If your browser already ships the Temporal global (Firefox 139+, Chrome/Edge 144+), run the spring-forward comparison live. If not, the demo tells you so instead of erroring — in a real app you’d load the polyfill and it would run identically:
Can you use it yet? (July 2026)
Straight answer: carefully, with a polyfill. Here’s the honest state.
Temporal reached Stage 4 at TC39’s March 2026 meeting, which means it’s finished and folded into the ECMAScript 2026 specification. Stage 4 is as done as a proposal gets — the API is stable and won’t change.
Shipping is another matter. Native support has landed in Firefox (139+, since May 2025) and in Chrome and Edge (144, January 2026), plus Deno. But Safari has not shipped it in a stable release — it’s behind a flag in Technical Preview, with WebKit signalling default-on support later in 2026.
The polyfill is maintained by the same people who wrote the spec, so it matches native behavior closely:
npm install @js-temporal/polyfill
import { Temporal } from "@js-temporal/polyfill";
let today = Temporal.Now.plainDateISO();
When Safari ships and Temporal goes Baseline, you delete the import and change nothing else — the global Temporal is identical. That’s the nice part about polyfilling a finished standard rather than a moving target.
Summary
Dateis broken in ways you can’t fix: mutable, zero-indexed months, no time-zone type, unpredictable parsing. Temporal is the standard-library replacement, living in the globalTemporalnamespace.- The core idea is exact time vs wall-clock.
InstantandZonedDateTimename real points on the timeline;PlainDate,PlainTime,PlainDateTime,PlainYearMonth, andPlainMonthDayare zone-free wall-clock values. Crossing between the two always needs a time zone. ZonedDateTime= instant + IANA zone + calendar. It’s the complete type and the one to use when a real moment across zones matters.Instantis a bare nanosecond timestamp.- Everything is immutable.
add,subtract,with,roundall return new values; there are no setters.until/sincemeasure the gap and hand back aDuration. Durationis a length of time;totalandroundcollapse and rebalance it, but crossing years/months/weeks into smaller units needs arelativeToanchor.- Months are one-based (July is
7), comparisons go throughequalsandcompare, andvalueOfthrows on purpose to stop silent coercion. - Status (July 2026): Stage 4 / ES2026, shipping in Firefox, Chrome, and Edge, but not in Safari stable — so not Baseline. Use the official
@js-temporal/polyfilland drop it once Safari catches up.