Articles › Programming

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.

Intermediate 7 min read October 23, 2026

What you will learn
  • 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

RuntimeSupported sinceNotes
Firefox139the first browser to ship Temporal
Chrome / Edge144released January 2026
Node.js26.0.0on by default, no flag
Safari (macOS, iOS)not in stableavailable 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:

DataType
When something happened (logs, created_at)Temporal.Instant
A meeting tied to a cityTemporal.ZonedDateTime
Birthday, holiday, due dateTemporal.PlainDate
Shop opens at 09:00Temporal.PlainTime
Monthly billing periodTemporal.PlainYearMonth
A length of timeTemporal.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.

Temporal: add 1 monthJan 31 + 1 monthclamps to Feb 28Date: setMonth(1)Jan 31 + 1 monthrolls over to Mar 3
January 31 plus one month: two APIs, two different days

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 compare you can pass straight to sort, and equals always 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 ===. Use compare and equals
  • Forgetting largestUnit. You get PT94500S instead of hours and minutes
  • Storing meetings as PlainDateTime. No zone means wrong times across zones; use ZonedDateTime
  • 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.