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.

interactiveTwo ways to read the same date string

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:

interactivesetMonth mutates in place — and overshoots

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.

Exact timea real point on the timelineInstantnanoseconds since epochZonedDateTimeinstant + time zone + calendarthe complete, unambiguous typeWall-clockwhat a clock or calendar readsPlainDateTimedate + time, no zonePlainDateY-M-DPlainTimeH:M:Sneed atime zoneGoing from a wall-clock value to a real instant is not free —you must supply which zone’s wall you mean.PlainYearMonthe.g. a billing monthPlainMonthDaye.g. a birthday
Temporal's types split along one axis: exact-time types name a real point on the universal timeline; wall-clock types name what a calendar or clock reads. Crossing between them always requires a time zone.

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.

one exact instant · epochMs 1783866600000Europe/Berlin16:30Wed 8 Juloffset +02:00America/New_York10:30Wed 8 Juloffset -04:00Asia/Tokyo23:30Wed 8 Juloffset +09:00
One Instant, read through three different time zones, yields three different ZonedDateTimes — all naming the identical point on the timeline.

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:

interactiveOne instant, four wall clocks

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 like 12-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
start2026-07-08still valid, unchanged.add({ days: 30 })later2026-08-07a brand-new object
add() reads the original and returns a fresh value. The original date is never touched, so sharing it is always safe.

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.

Temporal.Duration.from(“P1Y2M10DT2H30M”)calendar units1years2months10daysclock units2hours30minutes0sec … ns.sign → 1 · .years → 1 · .blank → false.total({ unit: “hours”, relativeTo }) collapses the whole thing to one numberyears/months/weeks have no fixed length, so total() past days needs a relativeTo anchor
A Duration is a bag of calendar and clock fields plus a sign. It measures length, not position — it doesn't know when it starts.
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();
From a legacy Date to a zoned wall-clock reading
1/4
Variables
legacy="Wed Jul 08 2026 14:30 UTC"
A legacy Date — one UTC timestamp, plus the machine's local zone baked in.

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]"
Sun 8 Mar09:00-05:00 (EST)DST gap (23h day).add({ days: 1 })Mon 09:00same wall time · only 23h passed.add({ hours: 24 })Mon 10:0024 real hours later
Across a spring-forward DST gap, adding 1 calendar day keeps the wall time (9am), while adding 24 hours advances real elapsed time and lands an hour later. Two different questions, two different answers.

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:

interactiveadd(days) vs add(hours) across a DST gap

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

  • Date is 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 global Temporal namespace.
  • The core idea is exact time vs wall-clock. Instant and ZonedDateTime name real points on the timeline; PlainDate, PlainTime, PlainDateTime, PlainYearMonth, and PlainMonthDay are 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. Instant is a bare nanosecond timestamp.
  • Everything is immutable. add, subtract, with, round all return new values; there are no setters. until/since measure the gap and hand back a Duration.
  • Duration is a length of time; total and round collapse and rebalance it, but crossing years/months/weeks into smaller units needs a relativeTo anchor.
  • Months are one-based (July is 7), comparisons go through equals and compare, and valueOf throws 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/polyfill and drop it once Safari catches up.