v4.0 is out with first-class time zones support!

Published by Sasha Koss's avatar Sasha Koss

This is Sasha @kossnocorp here.

I’m happy to announce the release of v4.0, which includes first-class time zone support and no major breaking changes.

For TL;DR, see the time zones doc and the change log entry.

Time zones support

It’s one of the most long-awaited features. There’s a fantastic third-party date-fns-tz by Marnus Weststrate that I have been recommending for years. I believe it started as a date-fns PR, which I never got to merge.

Truth be told, I didn’t have much experience working with time zones and had no vision for good API, and everything I saw proposed didn’t feel right. I knew just 20% of devs needed it, and the date-fns-tz to date-fns downloads ratio (3.2M/21.7M~15%) shows that I was right. I feared that if I were to introduce it, these 20% would generate 80% of bugs and support requests and distract from the core functionality. On top of that, before Intl API became widespread, it required a heavyweight IANA database. I also didn’t want to copy date-fns-tz and nullify Marnus’ work. So, it was a conscious decision not to support them natively.

In 2022, I came up with @date-fns/utc that immediately felt right. You get an option to make all the calculations in UTC and remove the local time zone from the equation without adding any extra weight to the core library. From then on, I knew I wanted to have something like that for all time zones, but it took me another two years to figure out how to approach it.

Now I’m finally happy to say it’s here. Just like with the UTC support, there’s an external package @date-fns/tz that provides TZDate (as well as a few helper functions) that works with all date-fns functions:

import { TZDate } from "@date-fns/tz";
import { addHours } from "date-fns";

// Given that the system time zone is America/Los_Angeles
// where DST happens on Sunday, 13 March 2022, 02:00:00

// Using the system time zone will produce 03:00 instead of 02:00 because of DST:
const date = new Date(2022, 2, 13);
addHours(date, 2).toString();
//=> 'Sun Mar 13 2022 03:00:00 GMT-0700 (Pacific Daylight Time)'

// Using Asia/Singapore will provide the expected 02:00:
const tzDate = new TZDate(2022, 2, 13, "Asia/Singapore");
addHours(tzDate, 2).toString();
//=> 'Sun Mar 13 2022 02:00:00 GMT+0800 (Singapore Standard Time)'

Before, in v3, you couldn’t mix UTCDate and Date in arguments without risking getting an unexpected result. Now, with v4, all functions normalize both arguments and the result to the first object argument (Date or a Date extension instance).

It allows you to mix and match different types of time zones without risking your calculations being wrong.

However, the results still might differ depending on the arguments’ order:

import { TZDate } from "@date-fns/tz";
import { differenceInBusinessDays } from "date-fns";

const laterDate = new TZDate(2025, 0, 1, "Asia/Singapore");
const earlierDate = new TZDate(2024, 0, 1, "America/New_York");

// Will calculate in Asia/Singapore
differenceInBusinessDays(laterDate, earlierDate);
//=> 262

// Will calculate in America/New_York
differenceInBusinessDays(earlierDate, laterDate);
//=> -261

So, to work around the issue, most of the date-fns functions (all where time zones might affect the result) accept the context option in that allows to specify what time zone to use explicitly:

import { tz } from "@date-fns/tz";

// Will calculate in Asia/Singapore
differenceInBusinessDays(laterDate, earlierDate);
//=> 262

// Will normalize to America/Los_Angeles
differenceInBusinessDays(laterDate, earlierDate, {
  in: tz("America/Los_Angeles"),
});
//=> 261

I’m very happy with the result, both from an implementation and API point of view. It gives all the power of time zone calculations without adding much overhead to the core library.

In addition, you can use both @date-fns/tz and @date-fns/utc without date-fns and still get value from them.

This is the date-fns approach I was looking for.

Bundle footprint

As I mentioned, the time zones don’t add much overhead to the core library, and most of the functionality is handled by external and optional @date-fns/tz. The Bundlephobia says that the total package size grew from 17.2 kB to 17.5 kB. That is a very irrelevant number by itself as it includes every single function and locale, but it is also illustrative. I had to update almost all of the 250 functions, which only amounted to a 300 B increase, which I think is very good.

As for TZDate itself, its minimal version, TZDateMini, is just 761 B, and the full version that adds strings formatting is 1 kB. That is half the size of what competitors offer.

UTCDate is even smaller, with UTCDateMini just 239 B and the full version 504 B, nearly five times smaller than the competition.

Breaking changes

In the v3.0 announcement, I suggested that I want to get rid of CommonJS support. Some of you’ll be happy to learn that date-fns v4.0 is still a dual ESM/CommonJS package. With v4.0 coming less than a year since v3.0, while the previous iteration took 4 years, and the overall negative impact of the ESM transition on the ecosystem, I decided to wait a little longer. The package is, however, now ESM-first and has "type": "module" in package.json, and instead of .mjs, it has .cjs. This should not affect anyone, except those doing something funny with the internals.

I also had to change some types, mostly function generics. Again, this should not affect normal usage but rare edge cases. I won’t be able to list all the possible ways TypeScript might complain after upgrading to v4.0, but if it does, I’m sure the fixes will be trivial.

This version breaks the tradition of packing major releases with many breaking changes, and I intend to keep it this way.

v5.0 and beyond

Another tradition that I violated is to have many years between releases. I’m happy about it and intend to have major versions thematic, ship more often, and with minimal breaking changes (or even without them), just like v4.0.

Now, after I finally tackle time zones, I will have more space for the missing features like durations, global locales, or any other blind spot of date-fns that remains, and I will make date-fns a feature-complete date-time library.

One of my biggest, more abstract goals is to finish the transition to the international API era and prepare for the soon-coming Temporal API.

With format still being the most used and the heaviest function at the same time, it would be beneficial to nudge developers to adopt a more lightweight and robust Intl API. I plan to extract custom locales and locale-dependant functions to separate packages like @date-fns/es and make the Intl-based format the default way to render dates.

I also have Interim API experiment in work, a lightweight Temporal API subset, to help developers start using it now and when it’s time to migrate—simply find-and-replace class names or imports.

Of course, the final game with Temporal API is to have a date-fns version that works with it while still maintaining the Date version of the library for years and maybe decades to come.

You can see that I don’t have concrete plans for v5.0. It is clear that it is a lot of work, so if you want to support date-fns or me personally, consider sponsoring me at GitHub, subscribing to my newsletter, or sending me a nice DM.

Cheers, Sasha @kossnocorp