JavaScript Temporal in practice: add months, compute ages, polyfill Safari
Temporal is Stage 4 and ships in Chrome 144, Firefox 139 and Node.js 26. Everyday code to replace Date, bridge old code, format dates, and polyfill only Safari.
- As of October 2026 Temporal runs in Chrome/Edge 144, Firefox 139 and Node.js 26, but not in stable Safari, so web code still needs a polyfill
- January 31 plus one month is February 28 in Temporal and March 3 with Date, and overflow: 'reject' turns it into an error instead
- until and since need a largestUnit, or you get the smallest unit, such as PT94500S instead of 26 hours 15 minutes
- Never compare with < or >; use compare and equals, because valueOf deliberately throws
JavaScript’s Date does one job well: it holds an instant as milliseconds. Everything else, from adding months and counting days to keeping a calendar date separate from a time, you write yourself or pull in a library for. Temporal is the new built-in API that fixes this, and in 2026 it really runs in Chrome, Firefox and Node.js. This article is the everyday code, every line run for real.
Tested on Node.js 26.12.1 (Windows 11), and temporal-polyfill 1.0.6 on Node.js 20.14. Browser status checked against MDN, caniuse and web.dev as of October 2026.
The storage rules (store UTC, keep the user’s zone, why adding 24 hours is not “tomorrow”) are in Dates and time zones. This article does not repeat them; it picks up with working code.
Status as of October 2026
| Runtime | Supported since | Notes |
|---|---|---|
| Firefox | 139 | the first browser to ship Temporal |
| Chrome / Edge | 144 | released January 2026 |
| Node.js | 26.0.0 | on by default, no flag |
| Safari (macOS, iOS) | not in stable | available in Technology Preview |
So MDN still lists it as “Limited availability”, not Baseline, because Safari lacks it. Server code on Node 26 can use it today; web code needs a polyfill (last section).
Pick the type by what the data means
Temporal has several types, and the whole point is choosing the one that matches the meaning:
| Data | Type |
|---|---|
| When something happened (logs, created_at) | Temporal.Instant |
| A meeting tied to a city | Temporal.ZonedDateTime |
| Birthday, holiday, due date | Temporal.PlainDate |
| Shop opens at 09:00 | Temporal.PlainTime |
| Monthly billing period | Temporal.PlainYearMonth |
| A length of time | Temporal.Duration |
Every type is immutable. Methods like add always return a new value, so the classic bug where setMonth() mutates an object someone else is holding goes away.
Bridging existing Date code
No rewrite needed; convert at the edges:
const d = new Date('2026-10-23T03:30:00Z');
const inst = d.toTemporalInstant(); // Temporal.Instant
inst.toString(); // '2026-10-23T03:30:00Z'
inst.epochMilliseconds; // 1792726200000
new Date(inst.epochMilliseconds); // back to a Date
Temporal.Instant.fromEpochMilliseconds(1792726200000).toString();
// '2026-10-23T03:30:00Z'
For the current time use Temporal.Now.instant(), which is nanosecond-precise (epochNanoseconds is a bigint).
Adding months: January 31 plus one month
The classic monthly-billing bug:
// Date: mutates, and overflows into the next month
const js = new Date(2026, 0, 31);
js.setMonth(1); // March 3, 2026
// Temporal: clamps to the end of the month
const jan31 = Temporal.PlainDate.from('2026-01-31');
jan31.add({ months: 1 }).toString(); // '2026-02-28'
jan31.add({ months: 1 }, { overflow: 'reject' });
// RangeError: there is no February 31
How Date lands on March 3, step by step: the month becomes February while the day stays 31 → February 2026 has 28 days → 3 days too many → it rolls over to March 3. Temporal’s default overflow: 'constrain' clamps to the last day of the month. When a human should decide (a contract that says “the 31st”), use 'reject' and handle the error.
Counting days and ages: until with largestUnit
const birth = Temporal.PlainDate.from('1990-02-28');
const today = Temporal.PlainDate.from('2026-10-23');
const age = birth.until(today, { largestUnit: 'years' });
age.toString(); // 'P36Y7M25D'
age.years; // 36
const due = Temporal.PlainDate.from('2026-12-31');
today.until(due).days; // 69
today.until(due, { largestUnit: 'months' }).toString(); // 'P2M8D'
Check by hand: 1990-02-28 + 36 years = 2026-02-28, + 7 months = 2026-09-28, + 25 days = 2026-10-23. The 69 days are 8 left in October + 30 (November) + 31 (December).
The trap is leaving out largestUnit. Between two Instants the default is seconds:
const a = Temporal.Instant.from('2026-10-23T03:30:00Z');
const b = Temporal.Instant.from('2026-10-24T05:45:00Z');
a.until(b).toString(); // 'PT94500S'
a.until(b, { largestUnit: 'hours' }).toString(); // 'PT26H15M'
94,500 ÷ 3,600 = 26.25 hours = 26 hours 15 minutes. Show the first form to a user and they cannot read it.
Durations convert the other way too:
const dur = Temporal.Duration.from({ minutes: 130 });
dur.round({ largestUnit: 'hours' }).toString(); // 'PT2H10M'
dur.total('hours'); // 2.1666666666666665
Monthly billing periods: PlainYearMonth
A billing period is not a date, it is a month. Store it as a PlainYearMonth and turn it into first and last days only when needed, without remembering how many days each month has:
const ym = Temporal.PlainYearMonth.from('2026-10');
ym.daysInMonth; // 31
ym.toPlainDate({ day: 1 }).toString(); // '2026-10-01'
ym.toPlainDate({ day: ym.daysInMonth }).toString(); // '2026-10-31'
ym.add({ months: 4 }).toString(); // '2027-02', crosses the year for you
Temporal.PlainYearMonth.from('2028-02').daysInMonth; // 29 (leap year)
In the browser, Temporal.Now.timeZoneId() reads the user’s zone (the test machine returned 'Asia/Bangkok'). Send it to the server and store it with the account, so the server can work out the user’s “today” correctly.
Comparing and sorting: no < or >
const list = ['2026-11-02', '2026-10-30', '2026-12-01'].map(s => Temporal.PlainDate.from(s));
list.sort(Temporal.PlainDate.compare); // 2026-10-30, 2026-11-02, 2026-12-01
list[0].equals(Temporal.PlainDate.from('2026-10-30')); // true
list[0] < list[1];
// TypeError: Do not use Temporal.PlainDate.prototype.valueOf;
// use Temporal.PlainDate.prototype.compare for comparison.
valueOf throws on purpose, to stop comparisons that would silently be wrong. === does not throw, but it checks object identity, not equal values; use equals.
Every type has a static
compareyou can pass straight tosort, andequalsalways replaces===.
Rounding: booking slots every 15 minutes
const t = Temporal.PlainTime.from('09:50');
t.round({ smallestUnit: 'minute', roundingIncrement: 15 }).toString();
// '09:45:00' (default halfExpand: 50 is closer to 45 than to 60)
t.round({ smallestUnit: 'minute', roundingIncrement: 15, roundingMode: 'ceil' }).toString();
// '10:00:00' (round up, for the next free slot)
Locale formatting, including other calendars
toLocaleString takes the same options as Intl.DateTimeFormat. Some locales default to a non-Gregorian calendar; Thai (th-TH) uses the Buddhist era, so you never add 543 by hand:
const z = Temporal.ZonedDateTime.from('2026-10-23T10:30[Asia/Bangkok]');
z.toLocaleString('en-GB', { dateStyle: 'long', timeStyle: 'short' });
// '23 October 2026 at 10:30'
z.toLocaleString('th-TH', { dateStyle: 'long', timeStyle: 'short' });
// '23 ตุลาคม 2569 เวลา 10:30'
today.withCalendar('buddhist').year; // 2569
Stored values stay in the ISO calendar; other calendars are a display concern, as the dates article linked above explains.
Through JSON and APIs
JSON.stringify({ due, at: a });
// '{"due":"2026-12-31","at":"2026-10-23T03:30:00Z"}'
toJSON returns ISO strings. The receiver has to call from() itself because JSON carries no types, so state in the API contract which fields are a PlainDate (no time) and which are an Instant (ends in Z).
A polyfill for Safari
Load the polyfill only when the real thing is missing, so browsers that have Temporal download nothing extra:
npm i temporal-polyfill
if (typeof globalThis.Temporal === 'undefined') {
await import('temporal-polyfill/global');
}
The same script ran on Node.js 20.14 (no Temporal, polyfill used) and on Node.js 26.12.1 (native) and printed 2026-02-28 on both. Top-level await needs an ES module; with a bundler, keep the polyfill in its own chunk.
Common mistakes
- Comparing with
<,>or===. Usecompareandequals - Forgetting
largestUnit. You getPT94500Sinstead of hours and minutes - Storing meetings as
PlainDateTime. No zone means wrong times across zones; useZonedDateTime Temporal.Now.plainDateISO()on a server. It returns “today” in the server’s zone, not the user’s. Always pass the zone, e.g.Temporal.Now.plainDateISO('Asia/Bangkok')- Assuming every browser has it. Stable Safari does not; ship the polyfill and test on a real iPhone
- Converting calendars before storing. Format at display time
Sources
FAQ
Should I remove Date everywhere?
No need to rewrite. Old code that stores an instant in a Date and sends ISO strings is already correct. Use Temporal where Date is painful, such as adding months, counting days, computing ages or cross-zone meetings, and convert at the edges with toTemporalInstant and epochMilliseconds.
Which polyfill should I use?
The two main ones are temporal-polyfill (from fullcalendar) and @js-temporal/polyfill (from the proposal team). As of October 2026, temporal-polyfill is at 1.0.6 and was updated this month, while @js-temporal/polyfill is at 0.5.1, last updated in March 2025. This article tested the first.
Which Node.js versions have it without installing anything?
Node.js 26 and later. Temporal has been enabled by default since 26.0.0 (May 2026). Node 24 and earlier still need a polyfill.
Commands and settings were tested on sample systems. Try them on a test machine before production.