Skip to content

Time Zones and DST

Time zones underlie most date-related bugs. zdate's answer is structural: the instant never carries a zone. A ZDate is epoch nanoseconds; a zone is part of a view of that instant. Converting between zones cannot change what moment in time you hold, because there is nothing to convert — only a different projection to read.

ts
const d = zdate('2024-01-15T12:00:00Z')

d.local.hour                          // your zone's view
d.with({zone: 'Asia/Tokyo'}).local.hour   // Tokyo's view — same instant
d.utc.hour                            // 12, always available

Offsets are resolved from the platform's IANA time zone data and cached as per-year offset range tables, so resolution after the first touch of a zone-year is arithmetic, not an Intl call.

DST Transitions

Wall-clock math (to() and by() with calendar units) can land on a time that occurs twice (a fold, when clocks go back) or never (a gap, when clocks jump forward). Internal operations use one fixed rule, matching JavaScript Date:

CaseBehavior
FoldThe earlier instant is selected
GapMove forward by the gap duration

Parsing is stricter than internal math. An external timestamp whose wall time is ambiguous or nonexistent is rejected rather than silently resolved — the source did not identify one instant, and that decision belongs to the application, not the parser. See Repairing Timestamps.

During a fold, a timestamp that includes either valid numeric offset is accepted; the offset selects the occurrence:

ts
zdate('2024-11-03T01:30:00-04:00[America/New_York]')  // first occurrence (EDT)
zdate('2024-11-03T01:30:00-05:00[America/New_York]')  // second occurrence (EST)
zdate('2024-11-03T01:30:00[America/New_York]')        // throws: ambiguous

Suffix Zone Is a Resolver, Not Identity

An RFC 9557 suffix zone names the rules used to resolve the timestamp's wall time to an epoch. It is not automatically adopted as the instance's local view. This matters when parsing timestamps from many sources: implicitly adopting each suffix would produce instances with inconsistent local views, making side-by-side comparison unreliable.

ts
// Assume the default zone is America/Chicago.
const d1 = zdate('2024-01-15T12:00:00[America/New_York]')
// epoch resolved via New York; .local view still in Chicago

const d2 = zdate('2024-01-15T12:00:00[America/New_York]', {zone: 'source'})
// opt in: suffix zone becomes the .local view

const d3 = zdate('2024-01-15T12:00:00[America/New_York]', {zone: 'Asia/Tokyo'})
// explicit zone overrides both

d1.parsedSuffix?.zone   // 'America/New_York' — the suffix is not lost

A suffix calendar is handled the same way: captured on parsedSuffix, adopted as the view calendar only via calendar: 'source'. One rule covers the whole suffix — 'source' adopts source data, and nothing is adopted implicitly.

Zone Objects

Each view exposes its zone as a ZTimeZone. Instances come from views — the class is not a package export:

ts
const zone = d.local.zone  // or d.utc.zone (both are a ZView)

zone.name                  // 'America/Chicago'
zone.offset                // offset in nanoseconds; negative west of UTC (CST is -21,600,000,000,000)
zone.abbr                  // 'CST'
zone.daylightSavings       // true when the offset in effect is not the zone's standard offset
zone.toISOString()         // '-06:00'
zone.toString()            // 'America/Chicago' — also what toJSON() emits

Both transition walkers accept an epoch anchor and default to now, returning a ZoneTransition{epoch, offset} — or undefined when the zone has no transition within the search horizon of roughly two years (UTC and fixed-offset zones):

ts
zone.nextTransition()       // the next offset change after now
zone.prevTransition(epoch)  // the last offset change before the given instant