Temporal API in JavaScript
The Temporal API is a new standard set of date and time types for JavaScript, meant to replace Date. It is a TC39 proposal that reached Stage 3, is already implemented in some engines and is available everywhere through the official polyfill. The core idea: immutable objects, a clear separation of the concepts "instant", "date", "time" and "time zone", plus accurate handling of the IANA time zone database.
Theory
TL;DR
Temporalis a new built-in date and time API; it does not extendDate, it replaces it.- Every object is immutable:
add(),subtract(),with()andround()return a new value. - Separate types for separate jobs:
Instant,PlainDate,PlainTime,PlainDateTime,ZonedDateTime,Duration,Now. - The time zone is stored as part of the value (
[Europe/Kyiv]), not just as a+03:00offset, so daylight saving transitions are handled correctly. - An invalid date raises a
RangeError(withoverflow: 'reject') or is clamped explicitly, instead of silently rolling over asnew Date(2025, 1, 30)does. - Months are numbered from 1, not from 0 as in
Date. - Formatting is delegated to
Intl.DateTimeFormat; older environments pull in@js-temporal/polyfill.
Quick example
// A date with no time
const date = Temporal.PlainDate.from('2025-10-16');
console.log(date.year, date.month, date.day); // 2025 10 16
// A time with no date
const time = Temporal.PlainTime.from('13:45:30');
console.log(time.hour); // 13
// A date and time with no time zone
const dt = Temporal.PlainDateTime.from('2025-10-16T13:45:30');
console.log(dt.toString()); // 2025-10-16T13:45:30Why Date is not enough
Date is one of the oldest and most problematic parts of the language: its API was copied from Java back in 1995 and has barely changed since.
| Problem | Example |
|---|---|
| Implicit time zones | new Date('2024-01-01') is parsed as UTC, while new Date('2024-01-01T00:00') is parsed as local time |
| Unpredictable arithmetic | new Date(2024, 1, 30) gives 1 March, because 30 February does not exist |
| Mutating methods | setMonth(), setHours() change the original object instead of returning a new one |
| Clumsy UTC handling | you juggle toISOString() and the getHours() / getUTCHours() pairs by hand |
| Zero based numbering | months are 0..11 while days of the month are 1..31 |
| No types for calendars, durations or zones | every small task pulled in a third party library: Moment, date-fns, Luxon |
That is why Temporal does not patch Date: it introduces a separate, consistent model of time.
The Temporal classes
| Class | What it represents | Example |
|---|---|---|
Temporal.Instant | a moment on the absolute timeline (a point in UTC) | "2025-10-16T21:00:00Z" |
Temporal.PlainDate | a date with no time | "2025-10-16" |
Temporal.PlainTime | a time with no date | "21:00:00" |
Temporal.PlainDateTime | a date and time with no time zone | "2025-10-16T21:00:00" |
Temporal.ZonedDateTime | date + time + time zone | "2025-10-16T21:00:00+03:00[Europe/Kyiv]" |
Temporal.Duration | a length of time, the difference between two moments | "P3DT5H" (3 days 5 hours) |
Temporal.Now | a helper for reading the current time | Temporal.Now.instant() |
The difference between PlainDateTime and ZonedDateTime is the key interview point. PlainDateTime is wall clock time with no place attached: "16 October at 21:00" means different instants in different countries. ZonedDateTime carries the zone, so it points at one specific instant and knows about DST transitions. When you need an actual instant (an event timestamp, a log record), use Instant or ZonedDateTime; when you need a birthday or an office opening time, use PlainDate / PlainTime.
Time zones and daylight saving time
const zdt = Temporal.ZonedDateTime.from({
timeZone: 'Europe/Kyiv',
year: 2025,
month: 10,
day: 16,
hour: 21,
});
console.log(zdt.toString());
// 2025-10-16T21:00:00+03:00[Europe/Kyiv]
console.log(zdt.toInstant().toString());
// 2025-10-16T18:00:00Z (the same moment in UTC)Temporal keeps the zone identifier ([Europe/Kyiv]) as part of the data, not just the +03:00 offset. Because of that, arithmetic respects daylight saving transitions:
const beforeDST = Temporal.ZonedDateTime.from('2025-03-30T01:30:00+01:00[Europe/Berlin]');
const afterDST = beforeDST.add({ hours: 1 });
console.log(afterDST.toString());
// 2025-03-30T03:30:00+02:00[Europe/Berlin]The clock jumped from 01:30 to 03:30 because the clocks moved forward at 02:00: one real hour was added and the offset changed from +01:00 to +02:00.
The current time comes from Temporal.Now:
const now = Temporal.Now.instant();
console.log(now.toString()); // the current time in UTC
const kyiv = Temporal.Now.zonedDateTimeISO('Europe/Kyiv');
console.log(kyiv.toString());
// for example: 2025-10-16T23:50:00+03:00[Europe/Kyiv]Arithmetic, Duration and strict validation
Every operation returns a new value and leaves the original untouched:
const today = Temporal.PlainDate.from('2025-10-16');
const tomorrow = today.add({ days: 1 });
const lastWeek = today.subtract({ weeks: 1 });
console.log(tomorrow.toString()); // 2025-10-17
console.log(lastWeek.toString()); // 2025-10-09
console.log(today.toString()); // 2025-10-16, unchangedThe difference between two moments is a Temporal.Duration in ISO 8601 form:
const start = Temporal.PlainDateTime.from('2025-10-16T10:00');
const end = Temporal.PlainDateTime.from('2025-10-18T15:30');
const duration = end.since(start);
console.log(duration.toString()); // P2DT5H30M (2 days 5 hours 30 minutes)
console.log(duration.days, duration.hours, duration.minutes); // 2 5 30Invalid values are never fixed up behind your back. A string holding a date that does not exist throws right away, and for an object the overflow option decides:
// The old Date silently rolls the date over
new Date(2025, 1, 30); // 2025-03-02 (month 1 is February, and 30 February does not exist)
// Temporal: a string with a non existent date is an immediate error
Temporal.PlainDate.from('2025-02-30'); // RangeError
// For an object the default is overflow: 'constrain' (clamped to the end of the month)
Temporal.PlainDate.from({ year: 2025, month: 2, day: 30 }); // 2025-02-28
// Strict mode
Temporal.PlainDate.from({ year: 2025, month: 2, day: 30 }, { overflow: 'reject' }); // RangeErrorNote that in Temporal month: 2 is February, because months are numbered from 1. In Date the same position would mean March.
Benefits, formatting and support
| Benefit | What it gives you |
|---|---|
| Immutability | safe operations with no mutation and no accidental side effects |
| A clear model | the concepts "instant", "date", "time" and "zone" are kept apart |
| Accurate time zones | the IANA database, correct DST, the zone held inside the value |
No Date magic | errors instead of implicit corrections |
Works with Intl | user facing formatting without hand built strings |
ISO 8601 and Duration | simple work with differences and intervals |
Formatting is delegated to Intl.DateTimeFormat, so localisation looks familiar:
const zdt = Temporal.ZonedDateTime.from('2025-10-16T21:00:00+03:00[Europe/Kyiv]');
const fmt = new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'long' });
console.log(fmt.format(zdt));
// Thursday 16 October 2025 at 21:00:00 Eastern European Summer TimeWhere you can already use it:
- The newest engines ship
Temporalnatively (Firefox was first with a complete implementation), the rest are working on it. - In Node.js and older browsers you pull in the official polyfill.
- Before shipping to production check support on MDN or guard with
typeof Temporal !== 'undefined'.
npm i @js-temporal/polyfillimport { Temporal } from '@js-temporal/polyfill';
const date = Temporal.PlainDate.from('2025-10-16');
console.log(date.toString()); // 2025-10-16Common mistakes
- Expecting mutation.
dt.add({ days: 1 })changes nothing indt; you have to assign the result. This is the most common slip for anyone used tosetDate(). - Confusing
PlainDateTimewithZonedDateTime.PlainDateTimeis not a moment on the timeline: without a zone it cannot be compared to anInstantor stored as an event timestamp. - Storing only the offset.
+03:00is not a time zone: a week after the switch back to winter time the same place is at+02:00. Store the IANA identifier (Europe/Kyiv). - Zero based months. Porting code from
Dateit is easy to leavemonth: 0in place; in Temporal that is aRangeError, because January is1. - Counting on a
RangeErrorwhereconstrainapplies. By defaultfrom({...})clamps the value to the end of the month; pass{ overflow: 'reject' }if you want an error. - Mixing
DateandTemporalin calculations. Convert explicitly withdate.toTemporalInstant()andinstant.epochMilliseconds. - Pulling in a heavy library just for
Temporal. The polyfill is not small either, so for a single formatting call plainDateplusIntl.DateTimeFormatmay be enough.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.