Blog / Coding tips

JavaScript Temporal API Tutorial: Replace Date with Tested Examples (2026)

Cover image for JavaScript Temporal API Tutorial: Replace Date with Tested Examples (2026)

Temporal is JavaScript’s new built-in date and time API, designed to replace the old Date object. Instead of one confusing type, it gives you separate, immutable types for each job: Temporal.PlainDate for a calendar date such as a birthday, Temporal.ZonedDateTime for an exact moment in a named time zone, Temporal.Duration for lengths of time, and Temporal.Instant for a timestamp. Months are numbered 1 to 12, adding a month to 31 January gives 28 February instead of 2 March, and time zones are handled properly.

Temporal is trending because it has finally shipped in mainstream runtimes. Node.js 26.0.0, released on 5 May 2026, turned it on by default, and Node 26 is due to become the long-term support (LTS) release this October. Chrome 144 (13 January 2026) and Firefox 139 (27 May 2025) also support it. I ran every example below on Node.js 26.11.1 on 12 October 2026 and pasted the real output.

Why replace Date?

Date has been in JavaScript since 1995 and has well-known problems:

  • Months start at 0. new Date(2026, 0, 31) is 31 January, not a mistake you’d guess.
  • It’s mutable. setMonth() changes the object in place, so passing a date to a function can silently change it for the caller.
  • Only two time zones. A Date only knows UTC and the computer’s local zone. Showing a meeting time in London and Dhaka needs extra libraries.
  • Surprising arithmetic. Here’s what happens when you add a month to 31 January with Date:
const old = new Date(2026, 0, 31);
old.setMonth(old.getMonth() + 1);
console.log(old.toISOString().slice(0, 10));
2026-03-02

There’s no 31 February, so Date rolls over into March. Temporal’s answer to the same question is coming up below. If you just need to format an existing Date, my guide to formatting a date as dd/mm/yyyy in JavaScript still works; Temporal is for when you need real date maths.

Check that Temporal is available

Temporal is a global object, like Math. Feature-detect it before relying on it:

console.log(typeof globalThis.Temporal);

On Node.js 26 this printed object. On the box’s older Node.js 20 it printed undefined. Browser support, from the web-features explorer on 12 October 2026, is:

  • Chrome and Edge: supported from version 144 (January 2026).
  • Firefox: supported from version 139 (May 2025).
  • Safari (macOS and iOS): not supported yet, which is why Temporal is still marked “limited availability” rather than Baseline.

For public websites, that Safari gap matters. Use a polyfill such as @js-temporal/polyfill or temporal-polyfill when globalThis.Temporal is missing, or keep Temporal to server-side code on Node 26 for now.

The Temporal types at a glance

Picking the right type is most of the work. Here’s the short version:

  • Temporal.PlainDate: a calendar date with no time or zone. Birthdays, deadlines, bank holidays.
  • Temporal.PlainTime: a wall-clock time with no date. Opening hours, alarm times.
  • Temporal.PlainDateTime: a date and time with no zone. Rarely the right choice for real events, but useful for form inputs before you know the zone.
  • Temporal.ZonedDateTime: an exact moment plus a time zone such as Europe/London. Meetings, flights, anything that happens at a real moment for people in a place.
  • Temporal.Instant: an exact moment with no zone, like a timestamp in a database.
  • Temporal.Duration: a length of time, like “2 months and 2 days”.
  • Temporal.PlainYearMonth and Temporal.PlainMonthDay: “February 2026” or “25 December” on their own.

PlainDate: working with calendar dates

Create a date from an ISO string (YYYY-MM-DD) and read its parts:

const d = Temporal.PlainDate.from("2026-10-12");
console.log(d.toString(), d.year, d.month, d.day, d.dayOfWeek, d.daysInMonth, d.inLeapYear);
2026-10-12 2026 10 12 1 31 false

October is month 10, not 9. dayOfWeek runs from 1 (Monday) to 7 (Sunday), so 12 October 2026 is a Monday. Useful properties like daysInMonth and inLeapYear are built in.

To get today’s date in the computer’s time zone, use Temporal.Now.plainDateISO(). For the current exact moment, use Temporal.Now.instant().

Start and end of the month

with() returns a copy with some fields replaced:

console.log(d.with({ day: 1 }).toString(), d.with({ day: d.daysInMonth }).toString());
2026-10-01 2026-10-31

Adding days and months (and the 31 January problem)

Temporal objects are immutable: add() and subtract() return a new object and leave the original alone.

const start = Temporal.PlainDate.from("2026-10-12");
const later = start.add({ days: 7 });
console.log(start.toString(), later.toString());
2026-10-12 2026-10-19

Now the same month problem that tripped up Date:

const jan31 = Temporal.PlainDate.from("2026-01-31");
console.log(jan31.add({ months: 1 }).toString());
try {
  jan31.add({ months: 1 }, { overflow: "reject" });
} catch (e) {
  console.log(e.constructor.name + ":", e.message);
}
2026-02-28
RangeError: Temporal error: not a valid ISO date.

By default Temporal constrains the result to the last valid day, 28 February, which is what most people expect for “one month later”. If you’d rather catch the problem, pass { overflow: "reject" } and you get a RangeError instead. The exact error wording comes from Node’s engine and may differ in browsers.

Differences between dates: until() and since()

until() returns a Temporal.Duration. By default the largest unit for dates is days, but you can ask for months or years:

const exam = Temporal.PlainDate.from("2026-12-14");
console.log(start.until(exam).toString(), start.until(exam, { largestUnit: "months" }).toString(), start.until(exam).days);
P63D P2M2D 63

P63D is the ISO 8601 way of writing “63 days”, and P2M2D is “2 months, 2 days”. Read the parts with properties such as .days and .months.

Working out someone’s age

Age calculations are notoriously fiddly with Date. With Temporal, ask for years:

const birth = Temporal.PlainDate.from("2004-02-29");
console.log(birth.until(Temporal.PlainDate.from("2026-10-12"), { largestUnit: "years" }).years);
22

Even a leap-day birthday works without special-casing.

Comparing and sorting dates

Use Temporal.PlainDate.compare as a sort function and equals() for equality. Don’t use ===, which compares object identity, not dates:

const dates = ["2026-12-01", "2026-01-15", "2026-06-30"].map(s => Temporal.PlainDate.from(s));
console.log(dates.sort(Temporal.PlainDate.compare).map(String).join(", "), start.equals(Temporal.PlainDate.from("2026-10-12")));
2026-01-15, 2026-06-30, 2026-12-01 true

For skipping weekends and bank holidays, the logic in my post on counting UK working days in JavaScript becomes simpler with dayOfWeek and add({ days: 1 }).

ZonedDateTime: time zones done properly

This is where Temporal really shines. Create a moment in a named IANA time zone, then view it in other zones:

const meet = Temporal.ZonedDateTime.from("2026-10-12T15:00[Europe/London]");
console.log(meet.toString());
console.log(meet.withTimeZone("Asia/Dhaka").toString());
console.log(meet.withTimeZone("America/New_York").toPlainTime().toString());
2026-10-12T15:00:00+01:00[Europe/London]
2026-10-12T20:00:00+06:00[Asia/Dhaka]
10:00:00

A 3 pm meeting in London (on British Summer Time, +01:00) is 8 pm in Dhaka and 10 am in New York. The string keeps both the offset and the zone name, so it can be stored and read back without losing information.

Daylight saving time: days vs hours

UK clocks go back on Sunday 25 October 2026. Watch what happens when you add “1 day” versus “24 hours” across that change:

const beforeDst = Temporal.ZonedDateTime.from("2026-10-24T12:00[Europe/London]");
console.log(beforeDst.add({ days: 1 }).toString(), beforeDst.add({ hours: 24 }).toString());
2026-10-25T12:00:00+00:00[Europe/London] 2026-10-25T11:00:00+00:00[Europe/London]

Adding a day keeps the same wall-clock time (noon). Adding 24 hours lands at 11:00, because that particular day was 25 hours long. Temporal makes you choose which one you mean, which is exactly the kind of bug Date hides. The same issue exists on the server side; I covered it for PHP in setting the timezone to Europe/London in PHP.

Durations

Temporal.Duration represents a length of time. It doesn’t automatically “balance” units, but round() and total() will:

const dur = Temporal.Duration.from({ hours: 1, minutes: 90 });
console.log(dur.toString(), dur.round({ largestUnit: "hours" }).toString(), dur.total({ unit: "minutes" }));
PT1H90M PT2H30M 150

PT1H90M is kept exactly as you wrote it, rounding gives PT2H30M (2 hours 30 minutes), and total() converts it to a single number of minutes.

Plain times, date-times and month-days

The other Plain types follow the same pattern:

console.log(Temporal.PlainTime.from("09:15").add({ minutes: 50 }).toString());
console.log(Temporal.PlainDateTime.from("2026-10-12T23:30").add({ hours: 1 }).toString());
console.log(Temporal.PlainYearMonth.from("2026-02").daysInMonth, Temporal.PlainMonthDay.from("12-25").toPlainDate({ year: 2026 }).dayOfWeek);
10:05:00
2026-10-13T00:30:00
28 5

A 50-minute lesson starting at 09:15 ends at 10:05. Adding an hour to 23:30 rolls into the next day. February 2026 has 28 days, and Christmas Day 2026 is a Friday (day 5).

Converting between Temporal and Date

You’ll be mixing old and new code for a while, so conversions matter. An Instant maps cleanly to and from epoch milliseconds, and Date has a new toTemporalInstant() method:

const inst = Temporal.Instant.fromEpochMilliseconds(Date.UTC(2026, 9, 12, 9, 30));
console.log(inst.toString(), inst.epochMilliseconds, new Date(inst.epochMilliseconds).toISOString());
console.log(new Date("2026-10-12T09:30:00Z").toTemporalInstant().toString());
2026-10-12T09:30:00Z 1791797400000 2026-10-12T09:30:00.000Z
2026-10-12T09:30:00Z

Note the old Date.UTC(2026, 9, ...) needs month 9 for October. That’s the zero-based month problem again, and one more reason to move to Temporal.

Formatting dates for people

Every Temporal type has toLocaleString(), which uses the same Intl options you may know from Date:

console.log(d.toLocaleString("en-GB", { dateStyle: "full" }));
console.log(meet.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }));
Monday, 12 October 2026
12 Oct 2026, 15:00

One gotcha I hit: combining dateStyle or timeStyle with individual options such as timeZoneName throws TypeError: Invalid option. That’s an Intl.DateTimeFormat rule, not a Temporal bug. Use either the style shortcuts or individual fields like { day: "numeric", month: "long", timeZoneName: "short" }.

Validation: rejecting bad dates

Temporal refuses to parse impossible ISO strings and non-ISO formats:

try { Temporal.PlainDate.from("2026-02-30", { overflow: "reject" }); }
catch (e) { console.log(e.constructor.name + ":", e.message); }
try { Temporal.PlainDate.from("12/10/2026"); }
catch (e) { console.log(e.constructor.name + ":", e.message); }
RangeError: Temporal error: Parsed day value not in a valid range.
RangeError: Temporal error: Invalid character while parsing year value.

In my tests, "2026-02-30" was rejected even without the overflow option, because ISO strings are validated strictly. If you build a date from separate fields, though, the default overflow: "constrain" clamps it: Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 }) gave 2026-02-28. For user input from a form, parse the parts yourself and pass { overflow: "reject" } so mistakes are caught rather than silently adjusted.

"12/10/2026" fails outright. That’s a feature: it’s ambiguous (12 October or 10 December?), and Temporal won’t guess.

Two practical examples

Adding working days

Skipping weekends is a loop over dayOfWeek (Saturday is 6, Sunday is 7):

function addWorkingDays(date, n) {
  let d = date;
  while (n > 0) {
    d = d.add({ days: 1 });
    if (d.dayOfWeek <= 5) n--;
  }
  return d;
}
const friday = Temporal.PlainDate.from("2026-10-16");
console.log(friday.dayOfWeek, addWorkingDays(friday, 3).toString());
5 2026-10-21

Three working days after Friday 16 October is Wednesday 21 October. Because add() never mutates, there’s no risk of changing the caller’s friday by accident.

A countdown across time zones

Here’s a countdown from 09:15 in Dhaka to a launch at 18:00 in London. My first attempt threw an error worth knowing about:

RangeError: Temporal error: Timezones must be the same if unit is a day unit.

Days can be 23 or 25 hours long depending on the zone, so Temporal refuses to count days between two different zones. Convert one side first:

function countdown(now, deadline) {
  const left = now.until(deadline.withTimeZone(now.timeZoneId), { largestUnit: "days", smallestUnit: "minutes" });
  return `${left.days}d ${left.hours}h ${left.minutes}m`;
}
const now = Temporal.ZonedDateTime.from("2026-10-12T09:15[Asia/Dhaka]");
const launch = Temporal.ZonedDateTime.from("2026-10-20T18:00[Europe/London]");
console.log(countdown(now, launch));
console.log(JSON.stringify({ launch }));
8d 13h 45m
{"launch":"2026-10-20T18:00:00+01:00[Europe/London]"}

JSON.stringify uses the full string with offset and zone, and Temporal.ZonedDateTime.from() reads it back to an equal value, so storing Temporal values in JSON or a text column is lossless.

Common Temporal mistakes

  • Using PlainDateTime for real events. Without a zone, “2026-10-25T01:30” is ambiguous on the night the clocks change. Use ZonedDateTime.
  • Comparing with === or <. Use equals() and compare().
  • Expecting Duration to balance itself. Call round() or total().
  • Forgetting Safari. Feature-detect and polyfill for browser code.
  • Assuming error messages are identical everywhere. Catch RangeError by type, not by message text.
  • Counting days between two different time zones. Convert one value with withTimeZone() first, as in the countdown example.
  • Storing a PlainDateTime when you meant an Instant. For logs and database timestamps, store an Instant (or a ZonedDateTime string) so the exact moment is never lost.

FAQ

What is the Temporal API in JavaScript?

Temporal is a built-in global object that provides modern, immutable date and time types, including PlainDate, ZonedDateTime, Instant and Duration. It’s designed to replace the older Date object.

Is Temporal supported in Node.js?

Yes. Node.js 26.0.0, released on 5 May 2026, enables Temporal by default with no flag. Node.js 20 and older don’t have it, so typeof Temporal returns "undefined" there.

Which browsers support Temporal?

As of 12 October 2026, Chrome and Edge 144+ and Firefox 139+ support it. Safari on macOS and iOS doesn’t yet, so use a polyfill for public websites.

What is the difference between PlainDate and ZonedDateTime?

PlainDate is just a calendar date with no time or time zone, like a birthday. ZonedDateTime is an exact moment in a specific time zone, such as a meeting at 15:00 in Europe/London.

How do I add months to a date in JavaScript without bugs?

Use Temporal.PlainDate.from("2026-01-31").add({ months: 1 }), which returns 28 February 2026. Pass { overflow: "reject" } if you’d rather get an error than an adjusted date.

How do I convert a Date to Temporal?

Call date.toTemporalInstant() to get a Temporal.Instant, then instant.toZonedDateTimeISO("Europe/London") if you need a time zone. To go back, use new Date(instant.epochMilliseconds).

Are Temporal objects mutable?

No. Every Temporal object is immutable. Methods such as add(), subtract() and with() return a new object and leave the original unchanged.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related posts