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 in RFC 9557 order
RFC 9557 specifies [u-ca=...] after the timezone offset. Swapping the order produces a string that looks correct but fails to parse with parseRfc3339 or convertZonedToCalendar.
Wrong
"2024-03-15T10:00:00[japanese][America/New_York]" // wrong order — annotation before zoneRight
"2024-03-15T10:00:00-05:00[America/New_York][u-ca=japanese]" // RFC 9557 orderConverting a calendar-annotated PlainDate to a zoned value drops the calendar
convertPlainDateTimeToZoned strips the [u-ca=...] annotation. The resulting zoned value is Gregorian-only. If you need the calendar preserved, annotate the zoned result explicitly.
Wrong
const z = convertPlainDateTimeToZoned("2024-03-15T00:00:00[japanese][Asia/Tokyo]");
// z is "2024-03-15T00:00:00+09:00[Asia/Tokyo]" — [u-ca=japanese] is goneRight
// annotate after conversion if the calendar must be preserved
const z = convertPlainDateTimeToZoned("2024-03-15T00:00:00[Asia/Tokyo]");
// "[u-ca=japanese][Asia/Tokyo]" — calendar annotation re-attached if neededAssuming every zoned/ function accepts the annotation
addZoned, subtractZoned, startOfZoned, and most arithmetic functions operate on the underlying Gregorian instant. The [u-ca=...] annotation is for display and parsing, not arithmetic.
Wrong
addZoned("2024-03-15T00:00:00[japanese][Asia/Tokyo]", 1, "month");
// arithmetic runs on the Gregorian date, not the Japanese calendarRight
// arithmetic is always on the plain Gregorian representation
addZoned("2024-03-15T00:00:00[Asia/Tokyo]", 1, "month");
// "2024-04-15T00:00:00+09:00[Asia/Tokyo]"Using offset instead of IANA timezone
Passing an offset like '-05:00' instead of 'America/New_York' loses DST awareness. '-05:00' is always EST, never EDT. The IANA zone identifier is the only correct argument.
Wrong
getZonedNow("-05:00"); // always EST — never switches to EDTRight
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 an empty string from every zoned function. 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
}Assuming addZoned/subtractZoned rejects DST gaps
addZoned and subtractZoned use the disambiguation option to resolve DST gaps, but they do not reject them by default. Use disambiguation: reject when a gap is an error condition.
Wrong
addZoned("2024-03-10T01:30:00-05:00[America/New_York]", 1, "hour");
// "2024-03-10T03:30:00-04:00[America/New_York]" — silently crossed the spring-forward gapRight
addZoned("2024-03-10T01:30:00-05:00[America/New_York]", 1, "hour", {
disambiguation: "reject"
}); // throws RangeError if the target is in the gapPassing offset: 'prefer' with disambiguation
The `offset` option ('prefer', 'use', 'ignore', 'reject') and the `disambiguation` option ('compatible', 'earlier', 'later', 'reject') control different things. Using 'prefer' together with disambiguation can produce surprising results — 'prefer' keeps the input's offset, while disambiguation resolves the ambiguity.
Wrong
setZoned("2024-03-10T02:30:00", "America/New_York", {
offset: "prefer",
disambiguation: "later"
}); // 'prefer' keeps the original offset — disambiguation is mootRight
setZoned("2024-03-10T02:30:00", "America/New_York", {
offset: "use",
disambiguation: "later"
}); // 'use' adopts the zone's offset — disambiguation applies'I passed disambiguation: 'reject' to setZoned and it didn't throw'
disambiguation: 'reject' only throws when the input time is ambiguous (a DST overlap), not when it falls in a gap. Gaps are resolved by the offset option. To reject both, combine offset: 'reject' with disambiguation: 'reject'.
Wrong
setZoned("2024-03-10T02:30:00", "America/New_York", { disambiguation: "reject" });
// 02:30 is in the gap, not ambiguous — no throwRight
setZoned("2024-03-10T02:30:00", "America/New_York", {
offset: "reject",
disambiguation: "reject"
}); // throws on gap AND overlapConfusing hasDaylightSaving, getDstTransitions, and isInDaylightSaving
hasDaylightSaving tells you whether a zone *ever* has DST. getDstTransitions returns the transition instants for a year range. isInDaylightSaving tells you whether a specific instant is in DST. They answer three different questions.
Wrong
hasDaylightSaving("America/New_York"); // true — the zone has 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]"); // true
getDstTransitions("America/New_York", 2024, 2024); // [Instant, Instant] — transitionsReaching for cycleZoned when calendar arithmetic is wanted (or vice versa)
cycleZoned cycles within a timezone's wall-clock cycles. addZoned/subtractZoned with calendar units handle month/year boundaries. They are not interchangeable.
Wrong
cycleZoned("2024-01-15T00:00:00[America/New_York]", { unit: "month", step: 1 });
// cycles months but does not handle year boundaries the way addMonths doesRight
import { addZoned } from "@northguild/gmt/zoned";
addZoned("2024-01-15T00:00:00[America/New_York]", 1, "month");
// "2024-02-15T00:00:00-05:00[America/New_York]" — calendar arithmetic