What you'll learn
Quick Answer
Temporal is the TC39-standardized API replacing JavaScript's Date object with immutable, timezone-aware objects like PlainDate, ZonedDateTime, and Duration. It reached Stage 4 in March 2026 and now ships natively in Chrome 144+, Firefox 139+, and Node 26+, but you can use every example below today through the @js-temporal/polyfill package.
Why the Date object needed replacing
JavaScript's Date object has been carrying the same design mistakes since 1995. It's mutable, so passing a Date into a function and calling .setMonth() on it silently changes the original object everywhere else it's referenced. Months are zero-indexed (January is 0) while days are one-indexed, which has caused more off-by-one bugs than almost any other API decision in the language. And Date only really understands two timezones well: your system's local timezone and UTC — anything else means manually juggling offset numbers.
Here's a concrete example of the arithmetic problem, run for real in Node.js on this machine:
const jsDate = new Date(2026, 0, 31); // Jan 31, 2026
jsDate.setMonth(jsDate.getMonth() + 1);
console.log(jsDate.toString());
// Tue Mar 03 2026 00:00:00 GMT+0530 (India Standard Time)You asked for "one month after January 31" and got March 3. Date doesn't have a concept of "the last day of next month" — it just adds 1 to the month number, computes February 31st, and since that day doesn't exist, it rolls the overflow into March. There's no warning, no error, nothing in the API to tell you this happened. Your billing-cycle code just quietly generates the wrong invoice date once a year.
Parsing is just as forgiving in the wrong way. new Date("2026-13-01") doesn't throw — it returns Invalid Date, a special object that passes typeof checks, survives being stored in a database record, and only reveals itself as broken the moment someone calls .getTime() or renders it, usually far away from where the bad string was created. Temporal was built specifically to close these two failure modes: silent arithmetic overflow and silent parse failure.
The Temporal namespace: one type per job
Instead of one Date class trying to represent "a calendar date," "a wall-clock time," "an exact instant," and "a date plus a timezone" all at once, Temporal splits these into distinct types. Each one only supports the operations that make sense for what it represents:
Temporal.PlainDate— a calendar date with no time or timezone attached (a birthday, a deadline, a due date on an invoice)Temporal.PlainTime— a wall-clock time with no date ("9:30 AM", store opening hours)Temporal.PlainDateTime— a date and time with no timezone attachedTemporal.PlainYearMonth/Temporal.PlainMonthDay— just a year-month (billing periods) or month-day (recurring anniversaries) with no day or year respectivelyTemporal.ZonedDateTime— an exact instant tied to a specific IANA timezone, the type you want for scheduling across regionsTemporal.Instant— a single point on the universal timeline, independent of any calendar or timezone, closest thing to a raw Unix timestampTemporal.Duration— a length of time ("3 months, 2 days"), returned by arithmetic operations like subtracting two dates
This sounds like more to learn than one Date class, and it is — but the split does real work. You can no longer accidentally call a timezone-dependent method on a value that never had a timezone, because PlainDate simply doesn't expose one. A whole category of "it works on my machine because my machine is in IST" bugs becomes a type error instead of a runtime surprise.
Arithmetic that returns new objects instead of mutating
Every Temporal object is immutable. Calling .add() or .subtract() returns a brand new object; the original is untouched. This alone eliminates the aliasing bugs that come from a shared Date reference getting mutated by a function three layers away. Here's real, executed output using the @js-temporal/polyfill package (more on why that's necessary below):
import { Temporal } from '@js-temporal/polyfill';
const d1 = Temporal.PlainDate.from('2026-01-31');
const d2 = d1.add({ months: 1 });
console.log(d1.toString()); // 2026-01-31 (unchanged)
console.log(d2.toString()); // 2026-02-28Notice the difference from the Date example above: adding one month to January 31st gives you February 28th, not March 3rd. Temporal's default overflow behavior is constrain — when the target month doesn't have a 31st day, it clamps to the last valid day of that month instead of spilling into the next one. If you'd rather be told about the overflow than have it silently constrained, you can ask for that explicitly:
try {
Temporal.PlainDate.from({ year: 2026, month: 1, day: 31 })
.add({ months: 1 }, { overflow: 'reject' });
} catch (e) {
console.log(e.constructor.name, e.message);
}
// RangeError value out of range: 1 <= 31 <= 28That's a real thrown RangeError with a real message, not a hypothetical. You get to choose, per call, whether an out-of-range result should be silently constrained or should fail loudly — a choice Date never gave you.
Timezone math that actually accounts for DST
Temporal.ZonedDateTime ties an instant to an IANA timezone identifier (like America/New_York, not a raw UTC offset), and every arithmetic and conversion operation on it accounts for that timezone's real rules, including daylight saving transitions. Converting between zones is a single method call:
const zdt = Temporal.ZonedDateTime.from('2026-03-08T12:00:00[America/New_York]');
console.log(zdt.toString());
// 2026-03-08T12:00:00-04:00[America/New_York]
const zdtIST = zdt.withTimeZone('Asia/Kolkata');
console.log(zdtIST.toString());
// 2026-03-08T21:30:00+05:30[Asia/Kolkata]Both lines above are real output. Notice New York is already showing -04:00 (EDT, daylight time) rather than the standard -05:00 (EST) — March 8, 2026 is the date US clocks spring forward, and Temporal resolved that automatically from the timezone identifier without being told. The 9-hour-30-minute gap to IST is also exact, because IST doesn't observe daylight saving and Temporal knows that.
The DST transition itself is worth seeing directly, because it's where hand-rolled date math usually breaks. On March 8, 2026, clocks in America/New_York jump from 2:00 AM straight to 3:00 AM — the wall-clock hour between 2:00 and 3:00 simply doesn't exist that day. Here's what adding one hour to a time just before that gap actually does:
const beforeDST = Temporal.ZonedDateTime.from('2026-03-08T01:30:00[America/New_York]');
const plusHour = beforeDST.add({ hours: 1 });
console.log(plusHour.toString());
// 2026-03-08T03:30:00-04:00[America/New_York]1:30 AM plus one hour lands on 3:30 AM, not the nonexistent 2:30 AM — Temporal's default disambiguation pushes the result forward past the gap. That's genuinely correct calendar behavior, and it's exactly the kind of edge case that a hand-rolled date.setHours(date.getHours() + 1) using plain Date gets wrong twice a year, in both directions, for every timezone that observes DST.
Comparing, sorting, and checking equality
Because Temporal objects are objects, not primitives, === compares references, not values — two separately-created dates representing the same day will never be === equal, even though they're logically identical. This trips people up coming from Date, where comparisons are usually done via .getTime() anyway. Temporal gives you explicit methods for this instead of a numeric workaround:
const a = Temporal.PlainDate.from('2026-06-01');
const b = Temporal.PlainDate.from('2026-06-01');
console.log(a.equals(b)); // true
console.log(a === b); // falseFor sorting, every Temporal type ships a static compare function built to be passed directly to Array.prototype.sort:
const dates = ['2026-05-01', '2026-01-15', '2026-12-25']
.map(s => Temporal.PlainDate.from(s));
dates.sort(Temporal.PlainDate.compare);
console.log(dates.map(d => d.toString()));
// [ '2026-01-15', '2026-05-01', '2026-12-25' ]No custom comparator function, no .getTime() subtraction trick — the sort just works because compare is written to the exact signature sort expects.
Parsing is strict on purpose
Where new Date("2026-13-01") quietly returns Invalid Date, the equivalent Temporal call fails immediately at the point of the mistake:
try {
Temporal.PlainDate.from('2026-13-01');
} catch (e) {
console.log(e.constructor.name); // RangeError
}This is a deliberate design philosophy running through the whole API: an invalid value is a programmer error, and programmer errors should throw where they happen, not three function calls and a database write later. Invalid Date is a notorious source of bugs precisely because it doesn't behave like an error — it behaves like a slightly broken date that only reveals the problem when something finally calls a method on it. Temporal refuses to hand you that landmine in the first place.
Using Temporal today, in 2026
Temporal reached TC39 Stage 4 at the March 2026 committee meeting, which means it's a finished, standardized part of the language rather than a moving proposal. Engine support is real but not universal yet: it ships unflagged in Chrome 144+, Firefox 139+, and Edge 144+, and in Node.js 26+. Safari currently only has it in Technology Preview.
This matters concretely for whoever is reading this on an older setup, because it's exactly what happened while researching this article. The Node.js version available in this environment is v22.16.0, which predates Node 26:
node --version
// v22.16.0
node -e "console.log(typeof Temporal)"
// undefined
node --experimental-temporal -e "console.log(1)"
// node: bad option: --experimental-temporalNo global Temporal, and no flag to turn it on — that flag doesn't exist on this build at all. Every code sample in this article was written and actually executed against the official @js-temporal/polyfill package instead, which implements the finished Stage 4 spec faithfully and is maintained by TC39 champions of the proposal itself:
npm install @js-temporal/polyfillimport { Temporal } from '@js-temporal/polyfill';
// use exactly as shown throughout this articleTwo honest caveats if you reach for the polyfill in a shipping project: it's a real dependency with real weight on your client bundle, not a zero-cost shim, so budget for it like you would any other library rather than assuming it's free. And once your actual deployment targets (browsers or your Node runtime version) cross into native support, you can delete the polyfill import and keep every line of code that uses Temporal.* completely unchanged — the polyfill's whole purpose is to be a drop-in stand-in for the same API, not a different one you'll have to migrate off later.
