Skip to content

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.

HIGH

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 zone

Right

"2024-03-15T10:00:00-05:00[America/New_York][u-ca=japanese]" // RFC 9557 order
HIGH

Converting 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 gone

Right

// 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 needed
HIGH

Assuming 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 calendar

Right

// 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]"
HIGH

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 EDT

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 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
}
HIGH

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 gap

Right

addZoned("2024-03-10T01:30:00-05:00[America/New_York]", 1, "hour", {
disambiguation: "reject"
}); // throws RangeError if the target is in the gap
HIGH

Passing 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 moot

Right

setZoned("2024-03-10T02:30:00", "America/New_York", {
offset: "use",
disambiguation: "later"
}); // 'use' adopts the zone's offset — disambiguation applies
HIGH

'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 throw

Right

setZoned("2024-03-10T02:30:00", "America/New_York", {
offset: "reject",
disambiguation: "reject"
}); // throws on gap AND overlap
MEDIUM

Confusing 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 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, 2024); // [Instant, Instant] — transitions
MEDIUM

Reaching 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 does

Right

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