Add zoned date time - #201
Conversation
Add ZonedDateTime, ZoneOffset, ZoneRules and ZoneOffsetTransition. Offsets for region-based zones come from Intl instead of a bundled time zone database. Rebuild both LocalDateTime conversions on top of it.
|
Thanks for your contribution! The approval and merge process is almost fully automated 🧙 Here's how it works:
☝️ Lastly, the title for the commit will come from the pull request title. So please provide a descriptive title that summarizes the changes in 50 characters or less using the imperative mood. Happy coding! 🎉 |
There was a problem hiding this comment.
Pull request overview
This PR introduces first-class time-zone and transition support by adding ZonedDateTime plus supporting types (ZoneOffset, ZoneRules, ZoneOffsetTransition), and refactors existing date/time types to integrate with these new abstractions while expanding test coverage and enforcing 100% Jest coverage.
Changes:
- Add region-based and fixed-offset time-zone rules via
ZoneRules(derived fromIntl) and transitions viaZoneOffsetTransition. - Add
ZonedDateTimeplus newatZone/with*APIs acrossInstant,LocalDateTime,LocalDate, andLocalTime. - Expand/adjust tests and enforce 100% global coverage in Jest; document time-zone limitations in the README.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/zoneRules.test.ts | Adds tests for ZoneRules behavior (offsets, transitions, gaps/overlaps, resolution). |
| test/zoneOffsetTransition.test.ts | Adds tests for ZoneOffsetTransition value semantics and behavior. |
| test/zoneOffset.test.ts | Adds tests for ZoneOffset parsing, range validation, comparison, and serialization. |
| test/zonedDateTime.test.ts | Adds comprehensive tests for ZonedDateTime construction, parsing, arithmetic, comparisons, and formatting. |
| test/weekday.test.ts | Adds tests for Weekday enum values and ordering. |
| test/timeZone.test.ts | Extends TimeZone tests for fixed offsets, prefixed IDs, normalization, and rule resolution. |
| test/periodDuration.test.ts | Expands PeriodDuration.isZero() coverage via table-driven tests. |
| test/localTime.test.ts | Adds coverage for LocalTime.now/nowIn and new with* APIs. |
| test/localDateTime.test.ts | Updates LocalDateTime.ofInstant expectations and adds tests for nanoseconds, default clock, with*, and atZone. |
| test/localDate.test.ts | Adds coverage for LocalDate.now/nowIn and new with* APIs. |
| src/zoneRules.ts | Introduces ZoneRules abstraction with fixed and Intl-derived region rules + transition probing. |
| src/zoneOffsetTransition.ts | Implements the transition value object used by ZoneRules and ZonedDateTime. |
| src/zonedDateTime.ts | Implements ZonedDateTime with parsing/formatting, transitions handling, arithmetic, and comparisons. |
| src/timeZone.ts | Refactors TimeZone into an abstract base; adds ZoneRegion and ZoneOffset implementations and normalization logic. |
| src/localTime.ts | Adds clock-based now/nowIn and with* field replacement APIs. |
| src/localDateTime.ts | Refactors instant conversion to use rules-based offsets; adds with* APIs and atZone. |
| src/localDate.ts | Adds clock-based now/nowIn and with* field replacement APIs. |
| src/instant.ts | Adds Instant.atZone(...) convenience to create ZonedDateTime. |
| src/index.ts | Exposes new public exports (ZonedDateTime, ZoneOffset, ZoneRules, ZoneOffsetTransition). |
| README.md | Documents Intl-based time-zone behavior and known limitations. |
| jest.config.js | Enforces 100% global coverage and configures coverage collection. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return new ZonedDateTime( | ||
| LocalDateTime.ofInstant(instant, zone), | ||
| zone.getRules().getOffset(instant), | ||
| zone, | ||
| ); |
There was a problem hiding this comment.
Good catch, fixed in 4ac51c4. The offset is now resolved once and reused to build the local date-time, so it is a single Intl lookup per call.
| public getRules(): ZoneRules { | ||
| return ZoneRules.ofFixed(this); | ||
| } |
There was a problem hiding this comment.
Fixed in 4465961. It now memoizes the rules like getId and ZoneRegion.getRules already do.
| it.each([ | ||
| ['America/Sao_Paulo', '1900-06-01T00:00:00Z', '-03:06:28'], | ||
| ['Europe/Paris', '1900-06-01T00:00:00Z', '+00:09:21'], | ||
| ['America/New_York', '1880-06-01T00:00:00Z', '-04:56:02'], | ||
| ])('resolves the sub-minute historical offset of %s at %s', ( |
There was a problem hiding this comment.
Leaving this one as is. longOffset needs ICU 69+, which the implementation already depends on, and every runtime that has it ships full tzdata with these LMT offsets. The README caveat is about zones whose early history was dropped, and these three are not among them. These assertions are also the only coverage of the sub-minute branch of the offset regex, so removing them would lose that.
| * representable instant, which is well past the last transition of every zone. | ||
| */ | ||
| private getOffsetAt(epochSecond: number): number { | ||
| const second = Math.max( |
There was a problem hiding this comment.
Clamping the instant may silently return an incorrect offset for region-based zones. For example, both of these instants are clamped to the same native Date, even though New York has different recurring winter and summer offsets:
const rules = TimeZone.of('America/New_York').getRules();
const winter = Instant.parse('+500000-01-15T12:00:00Z');
const summer = Instant.parse('+500000-07-15T12:00:00Z');
expect(rules.getOffset(winter).equals(rules.getOffset(summer))).toBe(false);Since Intl cannot resolve this range, I think it would be safer to reject unsupported instants instead of returning a plausible but incorrect offset.
There was a problem hiding this comment.
Agreed, rejecting is better than guessing. Fixed in 1acc7aa.
The check sits at the public entry points (getOffset, getValidOffsets, getTransition, getNextTransition, getPreviousTransition). getOffsetAt still clamps because the probes go 2 days out and the scans 5 years, so they can legitimately run past the boundary from an in-range input.
It is a real gap and not just a far future thing: Instant.MAX is around year 999999 while a native date only reaches 273790, so the whole tail was collapsing onto one offset. Two tests that locked in the old clamping were flipped to expect the error, and the README has a bullet for the new limit.
There was a problem hiding this comment.
The range check still misses local date-times whose offset moves the resulting instant beyond the native Date boundary:
const zone = TimeZone.of('America/New_York');
const dateTime = LocalDateTime.parse('+275760-09-13T00:00');
expect(() => dateTime.atZone(zone))
.toThrow('is outside the range the runtime supports.');This resolves to +275760-09-13T04:00:00Z, which is beyond Date.MAX. Can we validate the instant after applying the candidate offset?
There was a problem hiding this comment.
You are right, and it was worse than the snippet shows: getOffset on the resulting instant throws, so the zoned date-time you got back held an instant the same rules reject.
Fixed in 937cf96. Instead of checking each candidate instant, I kept a margin from the bound when checking the local date-time. Resolving one probes 2 days on either side and then reads the offset at local - offset, so the furthest read is 2 days plus 18 hours away. Local date-times closer to the bound than that are now rejected.
Nice side effect: the reads around a local date-time can no longer clamp at all, so clamping in getOffsetAt is now only reachable by the transition scan, where it just makes the offset look constant past the bound and reports no transition.
Your snippet throws now, MAX - 237600 still works, and one second past it throws.
There was a problem hiding this comment.
Fixed in the last commit
denis-rossati
left a comment
There was a problem hiding this comment.
Appart from Renan's review, LGTM
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/timeZone.ts:411
ZoneOffset.ofTotalSecondsformats invalid inputs usingZoneOffset.formatAmount(totalSeconds). For non-integers this can yield impossible IDs (e.g.+00:00:1.5), and forNaN/Infinityit can throw fromintDiv("The result overflows the range of safe integers.") instead of the intended offset-range error. Consider only callingformatAmountfor safe integers and otherwise interpolating the raw value viaString(totalSeconds).
if (!Number.isSafeInteger(totalSeconds) || Math.abs(totalSeconds) > ZoneOffset.MAX_SECONDS) {
throw new Error(
'Offset must be between -18:00 and +18:00, '
+ `but got ${ZoneOffset.formatAmount(totalSeconds)}.`,
);
Adds
ZonedDateTime, along withZoneOffset,ZoneRulesandZoneOffsetTransition.Offsets for region-based zones come from
Intl, so there is no time zone database to bundle or keep up to date. Gaps and overlaps are found by probing the offset around a point in time.TimeZonebecomes an abstract base that resolves to either a region or a fixed offset.TimeZone.ofkeeps its signature, so existing code is unaffected.Also included:
withmethods onLocalDate,LocalTime,LocalDateTimeandZonedDateTime.withYearandwithMonthmove an invalid day back to the last valid one,withDayrejects it.atZoneonInstantandLocalDateTime.jest.config.js. Reaching it coveredLocalDate.now,LocalTime.now,PeriodDuration.isZeroandWeekday, which had no tests.