How To Make Localized JavaScript Dates Stupid

Ethan Glover

What is the timezone of “2022-02-22”?

For the native Date object, it assumes UTC and converts that to your system time, theoretically your local time. In the example below, I'm using CST. Notice it is not midnight here; it's 6PM on the 21st instead of midnight of the 22nd. This was converted FROM UTC and TO CST.

1new Date('2022-02-22').toString() // Mon Feb 21 2022 18:00:00 GMT-0600 (Central Standard Time)

It's very important to note that in all of these cases we are dealing with a conversion from one timezone to another. We create the date, and convert it to something else to display it. There is never a case where this doesn't happen.

Using date-fns, it just assumes midnight your local time. Your local time is used as the base for the conversion. In this case it converts FROM CST and TO CST. Which gives us the illusion of a more intuitive API (more on that later).

1parseISO('2022-02-22').toString() // Tue Feb 22 2022 00:00:00 GMT-0600 (Central Standard Time)

Luxon does the same thing. It converts FROM CST and TO CST.

1DateTime.fromISO('2022-02-22').toString() // 2022-02-22T00:00:00.000-06:00

Explicitly Declared Timezone

If the date time is explicit, the libraries can be accurate and give us the correct date. (Spoiler: Always be explicit.)

1parseISO('2022-02-22T00:00:00.000+00:00').toLocaleString() // // Mon Feb 21 2022 18:00:00 GMT-0600 (Central Standard Time)
1DateTime.fromISO('2022-02-22T00:00:00.000+00:00').toLocaleString() // // 2022-02-21T18:00:00.000-06:00

For native Date, it makes no difference because it was already assuming UTC.

1new Date('2022-02-22T00:00:00.000+00:00').toString() // Mon Feb 21 2022 18:00:00 GMT-0600 (Central Standard Time)

But if we used another timezone with Date we get a correct conversion.

1new Date('2022-02-22T00:00:00.000+07:00').toString() // Mon Feb 21 2022 11:00:00 GMT-0600 (Central Standard Time)


When it comes to localization, we have to remember Date and Intl are two different things. This:

1new Date('2022-02-22').toLocaleString() // 2/21/2022, 6:00:00 PM

Is the same as this:

1new Intl.DateTimeFormat(
2	'en-US'
3	{ dateStyle: 'short', timeStyle: 'long' }
4).format(new Date('2022-02-22')) // 2/21/22, 6:00:00 PM CST

.toLocaleString() is not a part of the Date API. The Date API does not deal with timezones. While Intl allows us to change the string output with a given timezone. Date does not support changing does not support this it either without a plugin.

1new Intl.DateTimeFormat(
2	'en-US',
3	{ dateStyle: 'short', timeStyle: 'long', timeZone: 'Etc/UTC' }
4).format(new Date('2022-02-22')) // 2/22/22, 12:00:00 AM UTC

Never pretend time doesn't exist.

When a server doesn't explicitly use UTC timezone in an ISO format, it causes confusion. It doesn't matter if you care about time or not. All dates have timezones. If we just assume we don't care about time, we get bad results. The naive assumption that we can just use a “date” without time is ignoring how time works.

What happens if I publish a blog post right now on Apr. 8, 2023 at 1:38 PM but then store the publish date as ‘2023-04-08’? If I use Date to display it with locale formatting, it will show as Apr 7, 2023, 7:00:00PM in central time. Not only is the time completely wrong, but even if we only show the date, it will be the wrong date.

Users in Sao Paulo will see the date as Apr 7, 2023, 9:00:00PM. Sao Paulo users should at least be seeing Apr 8, 2023, 2:00:00 AM assuming all we lost was the time. (Which we think we don't care about.)

The date is still wrong and the time is still off. I stored incomplete information and now I have to make guesses about what I intended to store in the first place.

But I wanna.

Let's assume we can't store time and timezone with the published date. And even despite that, we want to insist on not treating server time as UTC time. There's no such thing as a date without a time and a date without a timezone. It's like trying to go up while pretending down doesn't exist.

But let's pretend we want to do something very dumb and say April 8, 2023 is April 8, 2023 everywhere in the world at the exact same time. How can we do that and localize the date string?

This isn't easy to get right. With native Date, you have to pretend it's UTC, even if the published date was CST. Which creates difficult to read code for the future. Why are we showing this as UTC to the users?

What we're doing is taking advantage of the fact that Date is dumb enough that you can trick it into thinking that dates can exist outside of time. We can give it an incomplete date and say, “convert this to UTC”. Because Date defaults to assuming a date is UTC, it will convert from UTC to UTC.

1new Date('2023-04-08').toLocaleString(
2	'en-US',
3	{ dateStyle: 'medium', timeStyle: 'medium', timeZone: 'America/Chicago' }
4) // Apr 7, 2023, 7:00:00 PM

For our libraries, at first, it seems like they're being more intuitive and getting the right results:

1parseISO('2023-04-08').toLocaleString('en-US', {
2	dateStyle: 'medium',
3	timeStyle: 'medium',
4	timeZone: 'America/Chicago',
5}) // Apr 8, 2023, 12:00:00 AM
1DateTime.fromISO('2023-04-08').toLocaleString('en-US', {
2	dateStyle: 'medium',
3	timeStyle: 'medium',
4	timeZone: 'America/Chicago',
5}) // Apr 8, 2023, 12:00:00 AM

But remember what we know about these libraries from before. They assume local time as the date to convert FROM. We're taking the happy path here by creating a CST time and converting it to CST. If it were daylight savings time locally (CDT) we would again have the wrong date. Or looking at Sao Paulo, we see we're on the wrong date once again. Without explicit timezone, we need to interpret this date as the timezone we are passing to locale string.

1DateTime.fromISO('2023-04-08').toLocaleString('en-US', {
2	dateStyle: 'medium',
3	timeStyle: 'medium',
4	timeZone: 'America/Sao_Paulo',
5}) // Apr 8, 2023, 2:00:00 AM

And because libraries default to system time, we need to convert to system time. And because .toLocaleString() defaults to system time, all we have to do is omit the timezone argument.

1parseISO('2023-04-08').toLocaleString('en-US', {
2	dateStyle: 'medium',
3	timeStyle: 'long',
4}) // Apr 8, 2023, 12:00:00 AM CDT

I want to stress, these dates are wrong. This is an abuse of these tools. And you should never under any circumstance treat dates in this manner.

I don't think the confusion around dates is due to the lack of intuitive APIs. This is due to humans not understanding dates. If we are going to show a date for a specific timezone we must know what the original timezone was. Without that, we don't have the information needed to display it accurately and we have to guess on origin. When we have to guess, it's often better to guess UTC and make the conversion rather than lazily accepting what we receive.

Date libraries are preferable over native Date, but by using them wrong, by using manual formatters like ‘date-fns format()’, for the users, we're not just disrespecting locale formatting, we're only adding to the problem and consistently showing users incorrect dates. The irony of convenient libraries is that none of them replace Intl, they only wrap around and reexpose these natives tools.

Moral of the story, whether you think you care about time and timezones or not, store them and use them.