Zoned Date Operations
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.
Mistakes
Section titled “Mistakes”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 zoneRight
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]"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 hereRight
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]"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 annotationRight
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 calendarUsing 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 zoneRight
import { getZonedNow } from "@northguild/gmt/zoned";
getZonedNow("America/New_York"); // switches between EST and EDT automaticallyNot 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}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 backRight
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 forwardaddZoned("2024-03-09T02:30:00-05:00[America/New_York]", { days: 1 }, { disambiguation: "earlier" });// "2024-03-10T01:30:00-05:00[America/New_York]"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 firedRight
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 overlapExpecting 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}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 functionRight
import { isInDaylightSaving, getDstTransitions } from "@northguild/gmt/zoned";
isInDaylightSaving("2024-07-01T12:00:00-04:00[America/New_York]"); // truegetDstTransitions("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" },// ]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 yearRight
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