Skip to main content

JS Dates Are About to Be Fixed

· 10 min read
Iago Lastra
Cofounder | TimeTime.in

The problem

Of all the recent changes coming to ECMAScript, my favorite by far is the Temporal proposal. This proposal is very advanced, and we can already use this API through the polyfill provided by the FullCalendar team.

This API is so incredible that I will likely dedicate several blog posts to highlighting its key features. However, in this first post, I will focus on explaining one of its main advantages: we finally have a native object to represent a "Zoned Date Time".

But... What is a "Zoned Date Time"?

Human dates vs JS dates

Well, when we talk about human dates, we usually say something like, "I have a doctor's appointment on August 4th, 2024, at 10:30 AM," but we omit the time zone. This omission makes sense because, generally, our interlocutor knows us and understands that when I talk about dates, I usually do so in the context of my time zone, Europe/Madrid.

Unfortunately, with computers, this is not the case. When we work with "Date" objects in JavaScript we are dealing with plain numbers.

If we read the official specification, it states:

"An ECMAScript time value is a Number, either a finite integral Number representing an instant in time to millisecond precision or NaN representing no specific instant"

Aside from the VERY IMPORTANT fact that dates in JavaScript are not UTC but POSIX, where leap seconds are completely ignored, the problem with having only numbers is that the original semantics of the date are lost. This is becaus given an human date we can get the equivalent js date but not the other way arround.

Let's consider an example: suppose I want to record the moment I make a payment with my card. Many people might be tempted to do something like this:

const paymentDate = new Date('2024-07-20T10:30:00');

Since my browser is on an CET timezone, when I write this the browser just "computes the number of milliseconds since the EPOX given this CET instant"

This is what we actually store in a date:

paymentDate.getTime();
// 1721464200000

This means that depending on how you read this information you will get a different "human date":

If we read this from the CET perspective I get 10:30:

d.toLocaleString()
// '20/07/2024, 10:30:00'

and if we read this from the ISO perspective we get 8:30:

d.toISOString()
// '2024-07-20T08:30:00.000Z'

Many people think that by working with UTC or communicating dates in ISO format, they are safe; however, this is not correct, as information is still lost.

UTC is not enough

Even when working with dates on an ISO format, including the offset, the next time we want to display that date, we only know the number of milliseconds that have passed since the UNIX epoch and the offset. But this is still not enough to know the human moment and time zone in which the payment was made.

Strictly speaking, given a timestamp t0, we can obtain n human-readable dates that represent it...

In other words, the function responsible for transforming a timestamp into a human-readable date is not injective, as each element of the set of timestamps corresponds to more than one element of the "human dates" set.

This happens in exactly the same way when storing ISO dates, as timestamps and ISO are two representations of the same instant:

And this also happens if you work with offsets because different timezones might have the same offset.

If you still don't see the problem clearly, let me illustrate it with an example. Imagine you live in Madrid and take a trip to Sydney.

A few weeks later, you return to Madrid and see a charge on your transaction statement that you don't recognize... a charge of 3.50 at 2 AM on the 16th? What was I doing? That night I went to bed early!... I don't understand.

After a while of being worried, you realize that the charge corresponds to the coffee you had the following morning since, as you've read this article, you've deduced that your bank stores all transactions in UTC, and the application translates them to the phone's time zone.

This may end up as an anecdote, but what if your bank applies a promotion of one free cash withdrawal per day? When does that day start and end? UTC? Australia?... Things get complicated, believe me...

At this point, I hope you're convinced that working with only timestamps is a problem that, fortunately, now has a solution.

ZonedDateTime

In addition to many other things, the new Temporal API introduces a Temporal.ZonedDateTime object specifically designed to represent dates and times with their corresponding time zone. They have also proposed an extension to RFC 3339 to standardize the serialization and deserialization of strings representing dates:

As an example:

   1996-12-19T16:39:57-08:00[America/Los_Angeles]

This string represents 39 minutes and 57 seconds after the 16th hour of December 19, 1996, with an offset of -08:00 from UTC, and additionally specifies the human time zone associated with it ("Pacific Time") for time-zone-aware applications to take into account.

Additionally, this API allows working with different calendars such as:

  • buddhist
  • chinese
  • coptic
  • dangi
  • ethioaa
  • ethiopic
  • gregory
  • hebrew
  • indian
  • islamic
  • islamic-umalqura
  • islamic-tbla
  • islamic-civil
  • islamic-rgsa
  • japanese
  • persian
  • roc

Among all these, the most common will be iso8601 (the standard adaptation of the Gregorian calendar) with which you will work most frequently.

Basic operations

Creating Dates

The Temporal API offers a significant advantage when creating dates, particularly with its Temporal.ZonedDateTime object. One of the standout features is its ability to effortlessly handle time zones, including those tricky situations involving Daylight Saving Time (DST). For example, when you create a Temporal.ZonedDateTime object like this:

const zonedDateTime = Temporal.ZonedDateTime.from({
year: 2024,
month: 8,
day: 16,
hour: 12,
minute: 30,
second: 0,
timeZone: 'Europe/Madrid'
});

You’re not just setting a date and time; you're ensuring that this date is accurately represented within the specified time zone. This precision means that regardless of DST changes or any other local time adjustments, your date will always reflect the correct moment in time.

This feature is especially powerful when scheduling events or logging actions that need to be consistent across different regions. By incorporating the time zone directly into the date creation process, Temporal eliminates the common pitfalls of working with traditional Date objects, such as unexpected shifts in time due to DST or time zone differences. This makes Temporal not just a convenience but a necessity for modern web development where global time consistency is crucial.

If you are curious about why this API is great read this article explaining how to deal with changes on Time Zone definitions.

Comparing dates

ZonedDateTime offers an static method named compare which given 2 ZonedDateTimes one and two will return:

  • −1 if one is less than two
  • 0 if the two instances describe the same exact instant, ignoring the time zone and calendar
  • 1 if one is greater than two.

You can easily compare dates on unusual cases like the repeated clock hour after DST ends, values that are later in the real world can be earlier in clock time, or vice versa:

const one = Temporal.ZonedDateTime.from('2020-11-01T01:45-07:00[America/Los_Angeles]');
const two = Temporal.ZonedDateTime.from('2020-11-01T01:15-08:00[America/Los_Angeles]');

Temporal.ZonedDateTime.compare(one, two);
// => -1
// (because `one` is earlier in the real world)

Cool built-ins

A ZonedDateTime has some precomputed attributes that will make your life easier, for example:

hoursInDay

The hoursInDay read-only property returns the number of real-world hours between the start of the current day (usually midnight) in zonedDateTime.timeZone to the start of the next calendar day in the same time zone.

Temporal.ZonedDateTime.from('2020-01-01T12:00-08:00[America/Los_Angeles]').hoursInDay;
// => 24
// (normal day)
Temporal.ZonedDateTime.from('2020-03-08T12:00-07:00[America/Los_Angeles]').hoursInDay;
// => 23
// (DST starts on this day)
Temporal.ZonedDateTime.from('2020-11-01T12:00-08:00[America/Los_Angeles]').hoursInDay;
// => 25
// (DST ends on this day)

Another cool attributes are daysInYear, inLeapYear

Transforming timezones

ZonedDateTimes offer a .withTimeZone method which allows to change a ZonedDateTime as we desire:

zdt = Temporal.ZonedDateTime.from('1995-12-07T03:24:30+09:00[Asia/Tokyo]');
zdt.toString(); // => '1995-12-07T03:24:30+09:00[Asia/Tokyo]'
zdt.withTimeZone('Africa/Accra').toString(); // => '1995-12-06T18:24:30+00:00[Africa/Accra]'

Basic arithmetics

We can use the .add method to add the date portion of a duration using calendar arithmetics. The result will automatically adjust for Daylight Saving Time using the rules of this instance's timeZone field.

The GREAT PART of this is that it supports playing with calendar arithmetics or plain durations.

  • Adding or subtracting days should keep clock time consistent across DST transitions. For example, if you have an appointment on Saturday at 1:00PM and you ask to reschedule it 1 day later, you would expect the reschedule appointment to still be at 1:00PM, even if there was a DST transition overnight.
  • Adding or subtracting the time portion of a duration should ignore DST transitions. For example, a friend you've asked to meet in in 2 hours will be annoyed if you show up 1 hour or 3 hours later.
  • There should be a consistent and relatively-unsurprising order of operations. If results are at or near a DST transition, ambiguities should be handled automatically (no crashing) and deterministically.
zdt = Temporal.ZonedDateTime.from('2020-03-08T00:00-08:00[America/Los_Angeles]');
// Add a day to get midnight on the day after DST starts
laterDay = zdt.add({ days: 1 });
// => 2020-03-09T00:00:00-07:00[America/Los_Angeles]
// Note that the new offset is different, indicating the result is adjusted for DST.
laterDay.since(zdt, { largestUnit: 'hour' }).hours;
// => 23
// because one clock hour lost to DST

laterHours = zdt.add({ hours: 24 });
// => 2020-03-09T01:00:00-07:00[America/Los_Angeles]
// Adding time units doesn't adjust for DST. Result is 1:00AM: 24 real-world
// hours later because a clock hour was skipped by DST.
laterHours.since(zdt, { largestUnit: 'hour' }).hours; // => 24

Computing differences between dates.

Temporal offers a method named .until which computes the difference between the two times represented by zonedDateTime and other, optionally rounds it, and returns it as a Temporal.Duration object. If other is earlier than zonedDateTime then the resulting duration will be negative. If using the default options, adding the returned Temporal.Duration to zonedDateTime will yield other.

This might look like an obvious operation but I encourage you to read the full spec to understand the nuances of it.

Conclusion

The Temporal API represents a revolutionary shift in how time is handled in JavaScript, making it one of the few languages that address this issue comprehensively. In this article, we've only scratched the surface by discussing the difference between human-readable dates (or wall clock time) and UTC dates, and how the Temporal.ZonedDateTime object can be used to accurately represent the former.

In future articles, we'll explore other fascinating objects such as Instant, PlainDate, and Duration.

I hope you enjoyed this introduction.

Happy coding! :)