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.

ZonedDateTime anatomy and DST-aware additionA ZonedDateTime combines an exact instant, an IANA timeZone, and a calendar. add({days:1}) keeps the local clock at 09:00 across the spring-forward gap, while add({hours:24}) advances the absolute instant and lands at 10:00 local.Temporal.ZonedDateTime = three partsexact instant (ns)IANA timeZonecalendar (iso8601)Sun 09:00EST -05:0002:00 to 03:00 gap (skipped).add({ days: 1 })Mon 09:00 EDTsame wall clock.add({ hours: 24 })Mon 10:00 EDT+24h absoluteCalendar units keep the clock. Time units keep the duration.

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

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.