NPM package link: https://www.npmjs.com/package/zoned-date
Terminology
Regarding date time:
- Wallclock: the values shown in your wall-clock, calendar, namely: year, month, date, day (weekday), hour, minute, second, millisecond, timezone offset.
- Epoch: a point in the timeline stream, identified by the number of seconds from a specific time in history.
At the same moment, epoch is the same everywhere, but wallclock is different depending on the observation place. For example, at the moment, it is 1694393213485 milisec since the midnight at the beginning of January 1, 1970, UTC, this value is the same everywhere. But wallclock value at JST is 9:46AM, at UTC is 0:46AM.
Rationale
In Javascript, all wallclock methods returns different results based on the runtime's config. date.getHours()
returns different results when running in client browser, in server, and in your local dev machine (for the date objects with same date.getTime()
value).
Perfect fix for date-related problems
I recently publish zoned-date, aiming to fix all date-related issues in Javascript.
Install
yarn add zoned-date
import {ZonedDate, OffsetDate} from 'zoned-date'
// or
import ZonedDate from 'zoned-date/ZonedDate'
import OffsetDate from 'zoned-date/OffsetDate'
Usage
ZonedDate
and OffsetDate
implement all Date's methods with the additional of timezone support.
-
OffsetDate
: when you know the offset of the timezone. This class is highly recommended. It is just math and the pure Date object, and always just works. -
ZonedDate
: you specify timezone by its name. The library usesIntl
internally to derive the offset. Specially,ZonedDate
explicitly support DST with the full support for Disambiguation option defined by Termporal proposal
Note: OffsetDate
is sub-class of Date
(new OffsetDate instanceof Date
is true
), while ZonedDate
is not (new ZonedDate instanceof Date
is false
).
Sample usage
const date = new OffsetDate('2020-01-01T03:00:00.000Z', {offset: 9})
console.log(date.hours) // return hours at GMT+9: 12
date.hours = 10 // set hours at GMT+9
date.hours = h => h - 1 // decrease by 1
console.log(date.toISOString()) // 2020-01-01T00:00:00.000Z
date.withMonth(1).withYear(y => y + 1) // returns a new OffsetDate object
Timezone conversion
const date = new ZonedDate('2021-09-04T05:19:52.001', {timezone: 'Asia/Tokyo'}) // GMT+9
console.log(date.hours === 5)
date.timezone = 'Asia/Bangkok' // GMT+7
console.log(date.hours === 5 - 9 + 7)
date.timezone = 'UTC'
console.log(date.hours === 5 - 9 + 24)
date.timezone = 'America/New_York' // GMT-4
console.log(date.hours === 5 - 9 + -4 + 24)
DST support
for (const [timezone, wallclock, disambiguation, expected] of [
// positive dst
// forward, dst starts
['Australia/ACT', '2023-10-01T02:00:00.000', undefined, 11],
['Australia/ACT', '2023-10-01T02:30:00.000', undefined, 11],
['Australia/ACT', '2023-10-01T02:30:00.000', 'compatible', 11],
['Australia/ACT', '2023-10-01T02:30:00.000', 'earlier', 10],
['Australia/ACT', '2023-10-01T02:30:00.000', 'later', 11],
// backward, dst ends
['Australia/ACT', '2023-04-02T02:00:00.000', undefined, 11],
['Australia/ACT', '2023-04-02T02:30:00.000', undefined, 11],
['Australia/ACT', '2023-04-02T02:30:00.000', 'compatible', 11],
['Australia/ACT', '2023-04-02T02:30:00.000', 'earlier', 11],
['Australia/ACT', '2023-04-02T02:30:00.000', 'later', 10],
// negative dst
// forward, dst starts
['America/Los_Angeles', '2023-03-12T02:00:00.000', undefined, -7],
['America/Los_Angeles', '2023-03-12T02:30:00.000', undefined, -7],
['America/Los_Angeles', '2023-03-12T02:30:00.000', 'compatible', -7],
['America/Los_Angeles', '2023-03-12T02:30:00.000', 'earlier', -8],
['America/Los_Angeles', '2023-03-12T02:30:00.000', 'later', -7],
// backward, dst ends
['America/Los_Angeles', '2023-11-05T01:00:00.000', undefined, -7],
['America/Los_Angeles', '2023-11-05T01:30:00.000', undefined, -7],
['America/Los_Angeles', '2023-11-05T01:30:00.000', 'compatible', -7],
['America/Los_Angeles', '2023-11-05T01:30:00.000', 'earlier', -7],
['America/Los_Angeles', '2023-11-05T01:30:00.000', 'later', -8],
]) {
const date = new ZonedDate(wallclock, {timezone, disambiguation})
console.assert(date.offset === expected)
console.log('ok')
}
for (const [timezone, wallclock, disambiguation, expected] of [
['Australia/ACT', '2023-10-01T02:30:00.000', 'reject'],
['Australia/ACT', '2023-04-02T02:30:00.000', 'reject'],
['America/Los_Angeles', '2023-03-12T02:00:00.000', 'reject'],
['America/Los_Angeles', '2023-11-05T01:00:00.000', 'reject'],
]) {
const date = new ZonedDate(wallclock, {timezone, disambiguation})
try {
date.time
} catch (e) {
console.log('ok')
continue
}
console.log('failed')
}
Real-world use cases
We highly recommend using OffsetDate
if you have a fixed timezone offset.
Suppose that you have a service in Japan (GMT+9), a dev team in India (GMT+5:30), the server in UTC (GMT), and some clients access the service from Los Angeles, California (which has DST).
When starting your client web app/or/(remote/local)server, you can do OffsetDate.defaultOffset = 9
. After that, all calls to date.getFullYear
, date.getMonth
, etc. will return exactly the same values (which is the wallclock at GMT+9) in all runtime. You can confidently serialize the OffsetDate value (with date.toISOString()
, or, date.getTime()
), send/receive to/from client/(remote/local)server/database.
For example, if using the pure Date
object, your client cannot specify Oct 01, 2023 02:30 AM to your service in Japan. because this wallclock does not exist in your client timezone.
OffsetDate
internally overwrites all wallclock-related methods to shift the date to UTC timezone before the manipulation, so, any wallclock is supported.
Compare to existing libraries
Clean API interface (definitely)
OffsetDate
is a sub-class of Date
, you can pass OffsetDate instance to anything required Date
. Additional properties are stored as private properties, they are not exposed without explicitly intention from lib author. Besides, we provide some convenient setter/getter and immutable edit, which are very straightforward, almost zero-brain muscle to memorize them.
Specifically, for example for fullYear
, OffsetDate
has:
const year = date.fullYear
date.fullYear = year
-
date.setFullYear(y => y + 1)
. We use Date's methods internally, so automatic shifting likedate.setDate(-1)
will work. date.getFullYear()
-
date.withFullYear(2023)
. Immutable edit.
Operand for the assignment can be undefined
(skip the assignment), number
, (currentValue: number) => undefined
(skip assignment), or, (currentValue: number) => number
.
ZonedDate
is not a sub-class of Date
, but we implement all Date and OffsetDate's methods, so, if you pass ZonedDate
to any places requiring Date
instance, it mostly works.
Explicitly and sophisticated DST support for named Timezone.
We provide a sophisticated support for 3 DST disambiguation options (compatible
, later
, earlier
, reject
) defined in Temporal Proposal.
Compared to existing library's solution such as date-fns-tz
which provides a too simple implementation (see implementation), and obviously this is not enough for DST.
Top comments (7)
I’m concerned that much of this article, and the library described herein, are unaware of common standards-based approaches to this stuff. DST and timezone are a lot more complex than simple math, they’re cultural and legal constructs which operating systems have baked in to avoid complexity. Setting
$TZ
, for example, will easily change the timezone behavior of your running code.Timezones are complicated, and getting them right frequently involves keeping up with hard-coded values provided by OS vendors. One should avoid writing these libraries without relying on what the operating system maintains for you.
Rolling your own timezone management code can lead to very difficult bugs, and can even result in data loss as you may lose track of which time was actually correct.
You likely want to consider looking into the
Intl
API which has been in Node and browsers (more or less isomorphically) since 2015.If this is your concern, OffsetDate is the recommendation. If you use OffsetDate instead of Date. Most wallclock-related methods return the same value regardless of TZ setting. I made OffsetDate exactly for that purpose. You can check "Real-World usecases" section in my post.
OffsetDate is just a subclass of Date. By default, its behavior is Date's behavior at TZ=UTC.
I said most methods because some are not overwritten such as
date.toLocaleString()
.Thanks for your comment. Your comment totally makes sense.
OffsetDate is simply math because it does not involve with any political or geological timezone setting.
ZonedDate is all about what you are saying about. It relies on Inlt underhood. Javascript has Intl, but there is no direct way to obtain the timezone offset from Timezone name. ZonedDate is just the perfect way for that purpose, because it just provides the hardest part, no more (unnecessary) utilities functionality like other libraries.
That looks... interesting? What are the advantages over alternatives such as:
They (OffsetDate, ZonedDate) are defenitely better than existing libraries.
date-fns-tz: the implementation is too simple to handle DST, especially DST disambiguation option support.
date-fns
relies on the Date interface for passing the value, making it impossible to perfectly support DST. For example, they will not be able to intializeOct 01, 2023 02:30 AM
in a client in Los Angeles (I haven't tried but theoretically it won't work). BothOffsetDate
andZonedDate
can do that in a perfect way with full options to solve ambiguality.moment tz: too old lib that is not recommended nowaday, in my understanding.
dayjs: I have checked its HP. It says: "if you know momentjs, you know dayjs". For zoned-date, we can say: "if you know Date, you know zoned-date'.
I have added new sections in my post for comparison and real-world usecases.
Nice. I'll definitely remember to use it wherever I need it. Do you plan on adding a
UtcDate
as a specialization ofOffsetDate
? Also, is there a github repo? It doesn't seem to be properly linked to the NPM packageGithub is here github.com/tranvansang/zoned-date
I will re-check it in the next publish.
UtcDate
is just exactlyOffsetDate
, because by defaultOffsetDate.defaultOffset = 0
. Sonew OffsetDate('2020-01-01T10:20:00.123Z').hours === 10
.