Date is the last unfixed corner of early JavaScript. It was ported from Java's java.util.Date in ten days in 1995 — and Java deprecated most of that class the very next year. We kept it. Three decades later, it still mutates in place, counts January as month 0, has no concept of a time zone beyond "the user's machine," and forces you to reach for Moment, date-fns, or Luxon the moment you do anything real.
Temporal is the standard library that replaces it. It's a top-level namespace (Temporal.PlainDate, Temporal.ZonedDateTime, Temporal.Duration, …), it's immutable, it's time-zone-aware by design, and it landed in the ECMAScript spec for ES2026. Every widget below runs live in your browser — natively where your engine already ships Temporal, and through the official polyfill otherwise.
Temporal.Now.zonedDateTimeISO('Europe/Kyiv').toString();
Expected: a live clock like 2026-07-01T18:30:12+03:00[Europe/Kyiv] across four cities, ticking each second.
Those clocks are four Temporal.Now.zonedDateTimeISO(zone) calls on a one-second interval. No Date, no manual offset math, no library. Let's go through what changed — and try each one.
1. Objects are immutable#
Date's mutators (setMonth, setDate, setHours…) change the object in place. Assign a Date to a second variable and you have two names for one object — mutate through either and both "change." This is the source of a whole category of "why did my start date move?" bugs.
Temporal values are immutable. Every operation — add, subtract, with, round — returns a new value and leaves the original alone.
const t1 = Temporal.PlainDate.from('2026-01-15');
const t2 = t1.add({ months: 1 });
Expected: t1 stays 2026-01-15; t2 is a fresh 2026-02-15. The legacy Date equivalent mutates both aliases.
2. Months start at 1#
new Date(2026, 1, 15) is not February 15th's sibling — it's March, because Date counts months from 0. Worse, out-of-range values don't error; they overflow into adjacent months and years silently. Type 12 below and watch the legacy column roll into next year.
Temporal counts months from 1, matching ISO 8601 and human intuition, and throws a RangeError on invalid input instead of guessing.
Temporal.PlainDate.from(
{ year: 2026, month: 13, day: 15 },
{ overflow: 'reject' },
);
Expected: a RangeError — with overflow: 'reject' month 13 is rejected, not silently rolled into 2027 the way new Date(2026, 13, 15) is.
3. Calendar math that respects the calendar#
Ask "how old is someone born on 1990-02-15?" with Date and you'll subtract two epoch millisecond values, divide by 1000 * 60 * 60 * 24 * 365.25, and quietly get the wrong answer around leap years and month boundaries.
Temporal does calendar arithmetic directly. end.since(start, { largestUnit: 'years' }) returns a Duration broken into years, months, and days — leap years and variable month lengths handled for you. Duration also rounds (round()) and collapses to a single unit (total()).
Temporal.PlainDate.from('2026-06-30')
.since('1990-02-15', { largestUnit: 'years' });
Expected: 36 years, 4 months, 13 days — leap years and variable month lengths handled automatically.
4. Time zones are first-class#
Date stores one instant in UTC and renders it in the machine's local zone. Want a specific zone? Your only tool is toLocaleString(..., { timeZone }), which hands back a string — dead text you can't do further math on.
Temporal.ZonedDateTime is a real object that carries its zone. PlainDateTime.toZonedDateTime(zone) pins a wall-clock time to a zone; withTimeZone(other) re-projects the same instant elsewhere. Here's the everyday version: pick a meeting time in one city, see it in five.
const kyiv = Temporal.PlainDateTime.from('2026-07-01T15:30')
.toZonedDateTime('Europe/Kyiv');
kyiv.withTimeZone('Asia/Tokyo').toString();
Expected: 15:30 in Kyiv is 21:30 in Tokyo — the same instant, projected into five cities at once.
5. Day-of-week without the off-by-one#
Date.getDay() returns 0 for Sunday, so every "skip the weekend" loop starts with a mental translation. Temporal.PlainDate exposes dayOfWeek as ISO 1–7 (Monday–Sunday), which makes "business days" trivial: add a day, keep it if dayOfWeek < 6.
let date = Temporal.PlainDate.from('2026-07-01');
let added = 0;
while (added < 10) {
date = date.add({ days: 1 });
if (date.dayOfWeek < 6) added++; // Mon–Fri
}
Expected: 10 business days after 2026-07-01 lands on 2026-07-15, with the four weekend days skipped.
6. Relative time from a clean instant#
"5 minutes ago," "in 3 hours" — feed strings for these and you'll fight DST and zone drift. Compute the gap on Temporal.Instant (a fixed point on the timeline, zone-free) and the number is exact. Pair it with the native Intl.RelativeTimeFormat for the wording.
const then = Temporal.PlainDateTime.from('2026-07-01T14:00')
.toZonedDateTime('Europe/Kyiv').toInstant();
Temporal.Now.instant().since(then).total({ unit: 'seconds' });
Expected: an exact second count that Intl.RelativeTimeFormat renders as, e.g., "90 minutes ago" — no DST skew.
7. Adding a day is not adding 24 hours#
This is the bug that ships to production. On a DST transition, a calendar day and 24 absolute hours are different amounts of time — one of them lands an hour off. Date can't even express the distinction. Temporal separates add({ days: 1 }) (calendar) from add({ hours: 24 }) (absolute) and adjusts the UTC offset for you.
const t = Temporal.ZonedDateTime.from('2026-03-08T01:30:00[America/New_York]');
t.add({ hours: 24 }); // absolute
t.add({ days: 1 }); // calendar
Expected: across the spring-forward jump the two results differ by an hour, and the UTC offset shifts from -05:00 to -04:00.
8. Sorting without a detour#
Sorting Dates means mapping to getTime() inside a comparator. Temporal ships a static comparator you pass straight to sort:
dates.sort(Temporal.PlainDate.compare);
Expected: the dates come back in ascending calendar order — no (a, b) => a.getTime() - b.getTime() detour.
Shipping it today#
Two lines to be native-first with a safe fallback:
import { Temporal as Polyfill } from '@js-temporal/polyfill';
export const Temporal = globalThis.Temporal ?? Polyfill;Engines that already ship Temporal use it and skip the polyfill's ~40 KB gzip entirely; everyone else gets a spec-accurate implementation. If bundle size matters on the critical path, load the polyfill with a dynamic import() behind the same feature check so it only downloads where it's missing — which is exactly what the demos on this page do. Track native availability on MDN and read the full API in the TC39 documentation.
Takeaways#
- Immutable by default — no more aliased dates mutating under you.
- 1-based months, strict parsing — invalid input throws instead of overflowing.
- Real time zones —
ZonedDateTimeis an object, not a formatted string. - Calendar-correct math —
since/until/round/totalhandle leap years and DST. - Native-first, polyfill-backed — one line to adopt it now, zero cost once your engine ships it.
Date isn't going anywhere — it's load-bearing across the web. But for new code, there's no longer a reason to reach for it, and no longer a reason to pull in a date library for the basics.