Working with ZonedDateTime Objects
Temporal.ZonedDateTime is the one Temporal type that knows everything about a moment: the exact instant, the wall-clock time a human reads, and the IANA zone that ties them together. Part of Modern Date Logic with the Temporal API. If you are new to Temporal, skim Getting Started with Temporal API before diving in here.
What breaks without it
The legacy Date object stores a single UTC number and borrows the host machine's timezone for every read. That works until you schedule a meeting for "9 AM in Berlin" on a server running in UTC, add a day across a spring-forward transition, or render a timestamp during server-side rendering where the host zone differs from the user's. The displayed time drifts by an hour, recurring events land in the wrong slot, and hydration mismatches flash the wrong value. ZonedDateTime removes the ambiguity by carrying the zone with the value and applying real DST rules to every calculation.
The hardest idea on this page: wall-clock days vs. absolute hours
A ZonedDateTime is an instant + an IANA timeZone + a calendar. The subtle part is how arithmetic behaves across a DST boundary. .add({ days: 1 }) preserves the wall-clock time — 9 AM stays 9 AM even though the real elapsed time was 23 or 25 hours. .add({ hours: 24 }) adds absolute time — exactly 24 hours of physical duration, which can land on a different wall-clock hour. The diagram below shows both paths across the US spring-forward night.
API reference
| Member | Signature | Returns | Timezone caveat |
|---|---|---|---|
Temporal.ZonedDateTime.from() |
from(item, opts?) |
ZonedDateTime |
String must carry [IANA/Zone]; bare local times throw |
Temporal.Now.zonedDateTimeISO() |
zonedDateTimeISO(tz?) |
ZonedDateTime |
Defaults to host zone — pass explicit tz on servers |
.add() / .subtract() |
add(Duration | object, opts?) |
ZonedDateTime |
Calendar units keep wall-clock; time units add absolute |
.until() / .since() |
until(other, opts?) |
Duration |
Operates on UTC instants — DST-safe |
.equals() |
equals(other) |
boolean |
Compares exact instant, not wall-clock |
.withTimeZone() |
withTimeZone(tz) |
ZonedDateTime |
Same instant, re-projected to a new zone |
.toInstant() / .toPlainDateTime() |
— | Instant / PlainDateTime |
Drops zone or instant, respectively |
.offset / .offsetNanoseconds |
property | string / number |
Reflects DST at that moment, not a fixed value |
Approach A: the legacy Date solution and why it falls short
// "Add one day to a 9 AM New York appointment" with legacy Date.
const appt = new Date('2024-03-09T09:00:00-05:00'); // 9 AM EST
const nextDay = new Date(appt.getTime() + 24 * 60 * 60 * 1000); // add 24h of ms
// In a process running in America/New_York, this reads as 10:00 AM, not 9:00 AM,
// because 2024-03-10 lost an hour to spring-forward. Date cannot keep the clock.
console.log(nextDay.toString());
Date only knows how to add absolute milliseconds. It has no concept of "the same wall-clock time tomorrow," and the result it displays depends on the host machine's zone. There is no safe way to express calendar-aware arithmetic with Date alone.
Approach B: the Temporal solution
import { Temporal } from '@js-temporal/polyfill';
const appt = Temporal.ZonedDateTime.from(
'2024-03-09T09:00:00-05:00[America/New_York]'
);
// Calendar unit: keep the wall clock at 09:00 the next day.
const sameClock = appt.add({ days: 1 });
console.log(sameClock.toString());
// '2024-03-10T09:00:00-04:00[America/New_York]' — still 9 AM, now EDT
// Time unit: add exactly 24 hours of real elapsed time.
const absolute = appt.add({ hours: 24 });
console.log(absolute.toString());
// '2024-03-10T10:00:00-04:00[America/New_York]' — 10 AM, because the day was 23h long
The choice is explicit and intentional, not an accident of host configuration. For the full mental model of mutation-free calendar math, see date arithmetic without mutations and its detailed walkthrough of adding months without overflow.
Disambiguating ambiguous and nonexistent local times
When you build a ZonedDateTime from a bare local time, DST transitions create two failure modes: a fall-back hour that exists twice and a spring-forward hour that does not exist at all. The disambiguation option decides what happens.
import { Temporal } from '@js-temporal/polyfill';
// Fall-back: 2024-11-03 01:30 occurs twice in New York.
const local = Temporal.PlainDateTime.from('2024-11-03T01:30:00');
// 'earlier' picks the first (pre-transition) occurrence, EDT -04:00.
const first = local.toZonedDateTime('America/New_York', { disambiguation: 'earlier' });
// 'later' picks the second (post-transition) occurrence, EST -05:00.
const second = local.toZonedDateTime('America/New_York', { disambiguation: 'later' });
console.log(first.equals(second)); // false — one hour apart in real time
// 'compatible' (the default) mirrors legacy Date behavior; 'reject' throws on ambiguity.
Use 'reject' in scheduling pipelines where a silently-shifted time is a data-integrity bug, and choose 'earlier'/'later' deliberately when a policy dictates which occurrence wins.
Production implementation
A reusable factory that validates input, supplies an explicit zone, and rejects ambiguity by default keeps drift out of your codebase.
import { Temporal } from '@js-temporal/polyfill';
export interface ZonedInput {
/** ISO local date-time WITHOUT offset, e.g. '2024-11-03T01:30:00'. */
local: string;
/** IANA identifier, e.g. 'Europe/Berlin'. Never a numeric offset. */
timeZone: string;
disambiguation?: 'compatible' | 'earlier' | 'later' | 'reject';
}
export function makeZoned({
local,
timeZone,
disambiguation = 'reject', // fail loud on DST ambiguity in scheduling code
}: ZonedInput): Temporal.ZonedDateTime {
let plain: Temporal.PlainDateTime;
try {
plain = Temporal.PlainDateTime.from(local); // throws on malformed input
} catch {
throw new Error(`Invalid local date-time: "${local}"`);
}
try {
// toZonedDateTime applies the real tzdata rules for this zone + instant.
return plain.toZonedDateTime(timeZone, { disambiguation });
} catch (err) {
// 'reject' surfaces gap/overlap times here instead of silently shifting them.
throw new Error(`Cannot resolve ${local} in ${timeZone}: ${(err as Error).message}`);
}
}
On servers, serverless functions, and edge runtimes, never call Temporal.Now.zonedDateTimeISO() without an argument — it reads the host zone, which is almost always UTC in production and rarely the user's. Pass the zone from the user's profile or a request header. In containers, keep tzdata current so recent DST legislation resolves correctly; a stale image will compute offsets from outdated rules. The arithmetic behind those offsets is covered in timezone offset math explained.
Edge cases
Spring-forward gap (nonexistent time)
import { Temporal } from '@js-temporal/polyfill';
// 2:30 AM does not exist on 2024-03-10 in New York.
const gap = Temporal.PlainDateTime.from('2024-03-10T02:30:00');
const compatible = gap.toZonedDateTime('America/New_York'); // default 'compatible'
console.log(compatible.toString());
// '2024-03-10T03:30:00-04:00[America/New_York]' — pushed forward into the next valid hour
Fall-back overlap (time exists twice)
Already shown above: the same local string maps to two distinct instants one hour apart. Decide which one your domain means.
Month-end rollover
import { Temporal } from '@js-temporal/polyfill';
const jan31 = Temporal.ZonedDateTime.from('2024-01-31T12:00:00-05:00[America/New_York]');
// Calendar arithmetic constrains to the last valid day rather than overflowing.
console.log(jan31.add({ months: 1 }).toPlainDate().toString()); // '2024-02-29' (leap year)
Re-projecting across zones
import { Temporal } from '@js-temporal/polyfill';
const tokyo = Temporal.ZonedDateTime.from('2024-06-01T09:00:00+09:00[Asia/Tokyo]');
// Same instant, viewed from another zone — the wall clock changes, the moment does not.
console.log(tokyo.withTimeZone('America/New_York').toString());
// '2024-05-31T20:00:00-04:00[America/New_York]'
Gotchas and common pitfalls
- Implicit host zone: calling
Temporal.Now.zonedDateTimeISO()with no argument. Fix: always pass an explicit IANA zone in server and edge code. - Legacy millisecond arithmetic:
date.getTime() + 86400000to "add a day." Fix: use.add({ days: 1 })so DST is respected. - Hardcoded numeric offsets: storing
-05:00instead ofAmerica/New_York. Fix: persist the IANA identifier; offsets change with legislation. - Ignoring disambiguation: trusting the
'compatible'default in scheduling. Fix: use'reject'to catch gap/overlap times, or choose'earlier'/'later'by policy. - Inventing
Temporal.Duration.between(): it does not exist. Fix: usestart.until(end)orend.since(start).
Testing checklist
| Scenario | Input | Expected |
|---|---|---|
| Add a day across spring-forward | 2024-03-09T09:00[NY].add({days:1}) |
2024-03-10T09:00 -04:00 |
| Add 24 hours across spring-forward | 2024-03-09T09:00[NY].add({hours:24}) |
2024-03-10T10:00 -04:00 |
| Nonexistent local time | 2024-03-10T02:30 → NY (compatible) |
03:30 -04:00 |
| Fall-back, earlier vs later differ | 2024-11-03T01:30 earlier vs later |
not .equals() |
| Month-end constrain | 2024-01-31[NY].add({months:1}) |
date 2024-02-29 |
Run your suite under several host zones to flush out implicit-zone assumptions:
# CI matrix — the result must be identical regardless of the host zone.
for tz in UTC America/New_York Asia/Tokyo Pacific/Chatham; do
TZ=$tz npx jest zoneddatetime
done
Frequently Asked Questions
Why does .add({ days: 1 }) give a different result than .add({ hours: 24 })?
Calendar units like days preserve the wall-clock time, so 9 AM stays 9 AM even when the day is 23 or 25 hours long across a DST transition. Time units like hours add absolute elapsed time, so .add({ hours: 24 }) advances the instant by exactly 24 hours and can land on a different local clock time.
When should I use ZonedDateTime instead of Instant?
Use ZonedDateTime when the wall-clock time and timezone matter to your domain: scheduling, business hours, and user-facing timestamps. Use Instant for UTC-only concerns such as logging, idempotency keys, and storage where timezone context is irrelevant.
How do I convert a legacy Date to a ZonedDateTime safely?
Use Temporal.Instant.fromEpochMilliseconds(date.getTime()).toZonedDateTimeISO(zone), supplying the IANA zone you intend to interpret the moment in. Validate the original Date's zone assumptions first so you do not lock in silent drift. A full round-trip walkthrough lives in the related guide on converting between Date and Temporal.Instant.
Is ZonedDateTime ready for production?
Yes, with @js-temporal/polyfill pinned to a fixed version. Native support is progressively shipping in browsers and Node.js, and edge runtimes may need explicit timezone-database bundling.
Related
- Modern Date Logic with the Temporal API — the parent overview.
- Compare ZonedDateTime across different timezones — instant vs. wall-clock comparison.
- Recurring event scheduling across DST — applying day arithmetic to repeating events.
- Convert a Date to a Temporal.Instant and back — interop with legacy
Date. - Date arithmetic without mutations — the immutable calculation model.