Skip to content

Zoned Date Operations

  • The core functions, which every industry uses.

Zoned operations in GMT expose DST disambiguation, calendar annotations, and timezone offset control that plain operations simply do not have. The mistakes here cover the ways that extra power bites.

HIGH

Writing the calendar annotation before the time zone

A calendar-annotated zoned string is the RFC 9557 form Temporal.ZonedDateTime#toString() writes: the time zone annotation first, then [u-ca=...]. A calendar annotation before the zone is not RFC 9557, and convertZonedToCalendar and the other calendar-aware zoned functions reject it.

Wrong

convertZonedToCalendar("2024-03-15T10:00:00+09:00[u-ca=japanese][Asia/Tokyo]", "iso8601");
// "" — the calendar annotation comes after the zone

Right

import { convertZonedToCalendar } from "@northguild/gmt/zoned";
convertZonedToCalendar("2024-03-15T10:00:00+09:00[Asia/Tokyo][u-ca=japanese]", "iso8601");
// "2024-03-15T10:00:00+09:00[Asia/Tokyo]"
HIGH

Passing a calendar-annotated value to convertPlainDateTimeToZoned

convertPlainDateTimeToZoned has no calendar grammar: a [u-ca=...] annotation makes the input invalid, and the result is an empty string. Convert the Gregorian wall time first, then express the zoned result in the calendar with convertZonedToCalendar.

Wrong

convertPlainDateTimeToZoned("2024-03-15T00:00:00[u-ca=japanese]", "Asia/Tokyo");
// "" — the annotation is invalid input here

Right

import { convertPlainDateTimeToZoned, convertZonedToCalendar } from "@northguild/gmt/zoned";
const z = convertPlainDateTimeToZoned("2024-03-15T00:00:00", "Asia/Tokyo");
// "2024-03-15T00:00:00.000+09:00[Asia/Tokyo]"
convertZonedToCalendar(z, "japanese");
// "2024-03-15T00:00:00+09:00[Asia/Tokyo][u-ca=japanese]"
HIGH

Assuming every zoned/ function accepts the annotation

Only the calendar-aware subset — addZoned, subtractZoned, diffZoned, diffZonedAsDuration, convertZonedToCalendar and the zoned interval functions — accepts a calendar-annotated zoned string, and there calendar units resolve in that calendar. Every other zoned/ function treats the annotation as invalid input.

Wrong

startOfZoned("2024-03-15T10:00:00+09:00[Asia/Tokyo][u-ca=japanese]", "day");
// "" — startOfZoned does not accept the annotation

Right

import { addZoned } from "@northguild/gmt/zoned";
addZoned("2024-01-31T00:00:00+09:00[Asia/Tokyo][u-ca=japanese]", { months: 1 });
// "2024-02-29T00:00:00+09:00[Asia/Tokyo][u-ca=japanese]" — month arithmetic in the Japanese calendar
HIGH

Using offset instead of IANA timezone

An offset like '-05:00' is not a time zone: it has no DST rules, so it could never become EDT. GMT's zoned functions reject it and return their sentinel. The IANA zone identifier, such as 'America/New_York', is the only correct argument.

Wrong

getZonedNow("-05:00"); // "" — an offset is not an IANA zone

Right

import { getZonedNow } from "@northguild/gmt/zoned";
getZonedNow("America/New_York"); // switches between EST and EDT automatically
HIGH

Not validating timezone before use

An invalid timezone string silently produces the invalid-input sentinel from every zoned function — an empty string, or null from those that return a number. Always run isValidTimeZone first.

Wrong

getZonedNow("not-a-zone"); // ""

Right

import { isValidTimeZone, getZonedNow } from "@northguild/gmt/zoned";
if (isValidTimeZone("America/New_York")) {
getZonedNow("America/New_York"); // safe
}
HIGH

Expecting addZoned to always move a DST-gap landing forward

When the date part of the duration lands in a spring-forward gap, addZoned, subtractZoned and intervalFromDurationZoned resolve it with your disambiguation, as TC39's AddZonedDateTime resolves the intermediate date-time: 'compatible' and 'later' move it forward, 'earlier' back, and 'reject' returns an empty string. Before 1.16.0 the landing always moved forward. Pass 'earlier' only when an earlier result is what you want, and check for the sentinel when you pass 'reject'.

Wrong

addZoned("2024-03-09T02:30:00-05:00[America/New_York]", { days: 1 }, { disambiguation: "earlier" });
// assumed "2024-03-10T03:30:00-04:00[America/New_York]", but returns
// "2024-03-10T01:30:00-05:00[America/New_York]" — 02:30 on 10 March is in the gap, and "earlier" moves back

Right

const next = addZoned("2024-03-09T02:30:00-05:00[America/New_York]", { days: 1 }, { disambiguation: "reject" });
if (next === "") {
// ask the user: the renewal time does not exist on 10 March
}
addZoned("2024-03-09T02:30:00-05:00[America/New_York]", { days: 1 });
// "2024-03-10T03:30:00-04:00[America/New_York]" — "compatible" moves forward
addZoned("2024-03-09T02:30:00-05:00[America/New_York]", { days: 1 }, { disambiguation: "earlier" });
// "2024-03-10T01:30:00-05:00[America/New_York]"
HIGH

Expecting disambiguation to run while offset: 'prefer' still fits

The offset option ('prefer', 'use', 'ignore', 'reject') decides whether the input's own UTC offset is kept, and the disambiguation option ('compatible', 'earlier', 'later', 'reject') resolves a wall time the offset does not settle. setZoned defaults to 'prefer', as Temporal's ZonedDateTime#with does: an offset that is still valid settles the time, so disambiguation never runs. Pass offset: 'ignore' to make disambiguation decide.

Wrong

setZoned("2024-11-03T01:45:00-05:00[America/New_York]", { minute: 0 }, {
disambiguation: "reject"
}); // "2024-11-03T01:00:00-05:00[America/New_York]" — -05:00 kept, reject never fired

Right

setZoned("2024-11-03T01:45:00-05:00[America/New_York]", { minute: 0 }, {
offset: "ignore",
disambiguation: "reject"
}); // "" — the offset is ignored, so reject fires on the overlap
HIGH

Expecting disambiguation: 'reject' to throw

GMT functions never throw: a rejected wall time returns an empty string. disambiguation: 'reject' fires whenever the wall time is not settled by the offset: always in a spring-forward gap, and in a fall-back overlap when you pass offset: 'ignore'. Check the result instead of wrapping the call in try/catch.

Wrong

try {
setZoned("2024-03-09T02:30:00-05:00[America/New_York]", { day: 10 }, { disambiguation: "reject" });
} catch {
// never reached — 02:30 on 10 March is in the gap, and the result is ""
}

Right

const r = setZoned("2024-03-09T02:30:00-05:00[America/New_York]", { day: 10 }, { disambiguation: "reject" }); // ""
if (r === "") {
// invalid input, or a wall time in a gap or an overlap
}
MEDIUM

Confusing hasDaylightSaving, getDstTransitions, and isInDaylightSaving

hasDaylightSaving tells you whether a zone observes DST around a reference instant. getDstTransitions returns the transitions in one year. isInDaylightSaving tells you whether a specific instant is in DST. They answer three different questions.

Wrong

hasDaylightSaving("America/New_York", { at: "2024-01-15T12:00:00Z" }); // true — the zone observes DST
// used to check whether a specific date is in DST — wrong function

Right

import { isInDaylightSaving, getDstTransitions } from "@northguild/gmt/zoned";
isInDaylightSaving("2024-07-01T12:00:00-04:00[America/New_York]"); // true
getDstTransitions("America/New_York", 2024);
// [
// { instant: "2024-03-10T07:00:00Z", offsetBefore: "-05:00", offsetAfter: "-04:00" },
// { instant: "2024-11-03T06:00:00Z", offsetBefore: "-04:00", offsetAfter: "-05:00" },
// ]
MEDIUM

Reaching for cycleZoned when calendar arithmetic is wanted (or vice versa)

cycleZoned moves one field around its own range and wraps without carrying into the next field. addZoned/subtractZoned with calendar units carry across month and year boundaries. They are not interchangeable.

Wrong

cycleZoned("2024-12-15T00:00:00-05:00[America/New_York]", "month", 1);
// "2024-01-15T00:00:00-05:00[America/New_York]" — wraps within the year

Right

import { addZoned } from "@northguild/gmt/zoned";
addZoned("2024-12-15T00:00:00-05:00[America/New_York]", { months: 1 });
// "2025-01-15T00:00:00-05:00[America/New_York]" — calendar arithmetic