JAVASCRIPT / DATES, TIME, AND INTERNATIONALIZATION
Temporal Instant, Duration, and ZonedDateTime
Move between Temporal.Instant and ZonedDateTime, and predict how a Duration behaves when the arithmetic crosses a DST transition.
What you will learn
- Convert Instant to ZonedDateTime with toZonedDateTimeISO and back with toInstant
- Predict why add({ days: 1 }) and add({ hours: 24 }) diverge across a DST shift
- Pass relativeTo to Duration.total once days or larger units are involved
- Choose earlier, later, compatible, or reject for wall times that repeat or vanish
Understanding Temporal Instant, Duration, and ZonedDateTime
A Temporal.Instant is only a count of nanoseconds since the Unix epoch, so it pins down a moment every observer agrees on while saying nothing about what any clock read. It has no year, no hour and no zone, which is why toString() renders it in UTC with a trailing Z: that is the one form that needs no extra information. Arithmetic on an Instant is restricted to hours and smaller for the same reason, because a unit like 'day' is a calendar idea and an Instant has no calendar.
A Temporal.ZonedDateTime is an Instant plus an IANA zone plus a calendar, so it answers both questions at once: the exact epochNanoseconds, and the wall-clock fields someone in that zone would read. Carrying the zone's transition rules lets it know things an Instant cannot, such as that offset moves from -05:00 to -04:00 in mid-March in New York, that hoursInDay is 23 on the day that happens, and that startOfDay() is not guaranteed to be 00:00. Converting with toZonedDateTimeISO or toInstant never moves the moment; it only changes which questions the value can answer.
A Temporal.Duration is a bag of counted units rather than a length of time, so { days: 1 } and { hours: 24 } are genuinely different values and only the second one is fixed at 86,400 seconds. ZonedDateTime.add() respects that split: date units are applied to the wall clock and the offset is resolved again afterwards, while time units are added straight to the exact time. The same asymmetry explains Duration.total(), where a day is assumed to be 24 hours if you give no relativeTo anchor, and weeks, months and years throw a RangeError rather than guess.
const start = Temporal.ZonedDateTime.from('2026-03-07T12:00:00-05:00[America/New_York]');
const plusHours = start.add({ hours: 24 });
const plusDay = start.add({ days: 1 });
console.log(start.toInstant().toString());
console.log(plusHours.toString());
console.log(plusDay.toString());
console.log(start.until(plusHours, { largestUnit: 'hour' }).toString());
console.log(start.until(plusDay, { largestUnit: 'hour' }).toString());
console.log(plusDay.hoursInDay);Instant fixes a moment, ZonedDateTime fixes a moment plus the clock rules used to read it, and a Duration has no exact length until one of them anchors it.
Worked examples
One Instant, three clocks
Shows that an Instant carries no zone and that rendering it in different zones changes nothing about the moment.
const launch = Temporal.Instant.from('2026-07-04T23:30:00Z');
for (const zone of ['America/New_York', 'Asia/Tokyo', 'UTC']) {
const local = launch.toZonedDateTimeISO(zone);
console.log(zone, local.toPlainDateTime().toString(), local.offset);
}
console.log(launch.epochMilliseconds);Example explained
Line 1launch is a single exact moment; the loop only changes how it is displayed, never what it is.
Line 2toZonedDateTimeISO attaches a zone and its rule table, producing wall-clock fields plus the offset in force at that moment.
Line 3New York prints -04:00 because early July is daylight time, while Tokyo has no DST and is +09:00 all year.
Line 4epochMilliseconds is unchanged by any of the conversions, which is why Instants are the safe thing to store and transmit.
A Duration is not a number of hours yet
Demonstrates that the same Duration totals differently depending on whether it is anchored to a zone.
const d = Temporal.Duration.from('P1DT2H30M');
const fallBack = Temporal.ZonedDateTime.from('2026-11-01T00:00:00-04:00[America/New_York]');
console.log(d.toString());
console.log(d.total('hours'));
console.log(d.total({ unit: 'hours', relativeTo: fallBack }));
console.log(Temporal.Duration.from({ minutes: 150 }).round({ largestUnit: 'hour' }).toString());Example explained
Line 1P1DT2H30M is the ISO form, where T separates the date part (1 day) from the time part (2h30m).
Line 2total('hours') has no anchor, so the day counts as a flat 24 hours and the result is 26.5.
Line 3With relativeTo pointing at 2026-11-01 in New York, that calendar day is 25 hours long, so the identical Duration totals 27.5.
Line 4round({ largestUnit: 'hour' }) rebalances 150 minutes into PT2H30M without changing how long it is.
The hour that happens twice
Shows how disambiguation picks between the two exact times a repeated wall-clock time can mean.
const wall = { year: 2026, month: 11, day: 1, hour: 1, minute: 30, timeZone: 'America/New_York' };
const earlier = Temporal.ZonedDateTime.from(wall, { disambiguation: 'earlier' });
const later = Temporal.ZonedDateTime.from(wall, { disambiguation: 'later' });
console.log(earlier.toString());
console.log(later.toString());
console.log(earlier.until(later, { largestUnit: 'hour' }).toString());
try {
Temporal.ZonedDateTime.from(wall, { disambiguation: 'reject' });
} catch (err) {
console.log(err.constructor.name);
}Example explained
Line 1New York runs 01:30 twice on 2026-11-01, so the property bag alone does not identify one instant.
Line 2'earlier' resolves to the daylight-time pass at -04:00 and 'later' to the standard-time pass at -05:00, with identical wall-clock fields.
Line 3until() with largestUnit 'hour' compares exact time, revealing the two values are a full hour apart.
Line 4'reject' refuses to guess and throws RangeError, which is what you want for a time a user typed in.
Important notes
Temporal is still rolling out across engines, so run these snippets in a browser that ships it or import Temporal from a polyfill such as @js-temporal/polyfill.
The mirror image of a repeated time is a missing one: 2026-03-08T02:30 does not exist in New York, and the default disambiguation 'compatible' pushes it forward to 03:30 rather than failing.
Common mistakes
Treating add({ days: 1 }) and add({ hours: 24 }) as interchangeable: starting from 2026-03-07T09:00 in New York, the hours version lands at 10:00 local, so a daily 09:00 reminder built that way slides an hour after every spring-forward.
Calling duration.total('hours') on a Duration containing days and assuming the zone was consulted: with no relativeTo every day counts as exactly 24 hours, and if the duration also has weeks, months or years you get a RangeError instead of a number.
Comparing with < or ===: Temporal objects throw a TypeError from valueOf instead of silently coercing, and === only compares object identity, so use Temporal.ZonedDateTime.compare(a, b) for ordering, or a.equals(b), which also requires the same zone and calendar.
Try it yourself
Change, predict, then run
Build a ZonedDateTime for 2026-11-01T00:00 in America/New_York, then print the results of add({ hours: 24 }), add({ days: 1 }), and hoursInDay. Explain to yourself why the gap between the two results points the opposite way from the March example.
Open the JavaScript workspaceCheck your understanding
A reminder is stored as the Instant 2026-03-08T06:30:00Z. Your code runs instant.toZonedDateTimeISO('America/New_York').add({ days: 1 }).toInstant(). How much exact time separates the result from the original instant?
- 23 hours, because the local clock keeps reading 01:30 while that night loses an hour
- 24 hours, because add() on a value derived from an Instant still moves exact time by 86,400 seconds
- 25 hours, because New York gains an hour during that night
- Nothing, because it throws: days cannot be added to a value that came from an Instant
Show answer
The conversion yields 2026-03-08T01:30-05:00 in New York; adding one calendar day preserves the wall-clock fields, giving 2026-03-09T01:30-04:00, and since the offset shifted at 02:00 that night only 23 hours of exact time elapsed. The tempting 24-hour answer is what add({ hours: 24 }) would produce, and that gap between calendar arithmetic and exact arithmetic is precisely what ZonedDateTime exists to express.