JS protip: Formatting a date/time according to locale
By Christophe Porteneuve • Published on Oct 26, 2022

Last updated on January 31, 2023, 04:04pm

This post is also available in French.

Ha, the joys of formatting dates and times. Sure, we could use a single, digit-based format and call it a day. This might be enough (especially for tech formats), but it's certainly not ideal, perhaps even way too ugly when we're displaying these to humans, with their cultures and formatting customs: their locales.

After all, when an English-language website displays 08/12/2022, how can we be sure they mean August 12 or December 8? Without knowing what locale they went with, there's no way to be certain.

Let's review the options we have for doing this right without bloating our app’s JS.

Legacy Date methods

First there was the toString() method of Date. It produces an abbreviated US format with date, time and time zone, using your browser profile's default time zone settings. The shape of it is quite reminiscent of Internet protocol headers, as defined most recently by RFC 5322:

// 🤨 Meh.  Fixed format, locale-ignorant

new Date(2022, 9, 26, 9).toString()
// => 'Wed Oct 26 2022 09:00:00 GMT+0200 (heure d’été d’Europe centrale)'

I intentionally left the display from a French-language browser profile here: notice how the time zone's name uses this locale, whilst everything else hardcodes English abbreviations. #CaptainConsistency

This format is actually the concatenation of, among other things, the output of the toDateString() and toTimeString() methods, that have been here all along too, just like toGMTString() (that got deprecated in favor of toUTCString(), as UTC replaced GMT many years ago). ES5 also threw in the very useful toISOString(), that uses the ISO8601 standard's format, used for instance by JSON and many other serialization standards for dates/times.

// 🤨 Meh.  Still fixed, locale-ignorant formats.

const when = new Date(2022, 9, 26, 9)
when.toDateString() // => 'Wed Oct 26 2022'
when.toTimeString() // => '09:00:00 GMT+0200 (heure d’été d’Europe centrale)'
when.toUTCString() // => 'Wed, 26 Oct 2022 07:00:00 GMT'
when.toISOString() // => '2022-10-26T07:00:00.000Z'
when.toJSON() // => '2022-10-26T07:00:00.000Z'

Yup, you read that right: toUTCString() uses a time zone name of... GMT 🤦🏻 sigh All in all, these aren't worth much when trying to display dates and times to humans.

So we use third-party libraries then?

Historically, in order to properly format dates and times, we had to use third-party libraries.

The most famous one in this space, still in widespread use despite its advocating for years to use something else, is Moment.js. That's a wonderful way to cargo-cult 300KB minified in our JS bundles for nothing (even if you setup your bundling to only include locales relevant to you, you're looking at a minimum 70KB weight).

For a few years now, Luxon (Moment's heir library) and date-fns have taken over, even if their core purpose isn't so much formatting as manipulation (time distances, moving forward or backward in time, etc.). These two are actually entirely based on the Intl API (ECMA-402 standard), which is part of JavaScript's standard library.

As a result, when it comes to formatting dates and times, the value added by these libraries is next to zero.

Intl.DateTimeFormat

We've had the Intl API for quite a few years now (standardized by the same committee working on JavaScript and JSON), and its entire purpose is to provide as full a JavaScript access to established data formatting as possible, for all standardized locales.

Dates and times, number formatting, lists, pluralization, collation / sorting... Every locale has their own conventions, habits, customs... And all of this is actively maintained in the Common Locale Data Repository, or CLDR, which is itself part of a larger project called the International Components for Unicode (ICU).

As you probably expect, this adds up to a large dataset, available on all OSes through one or more system libraries. On Ubuntu for instance, the libicu66 contains about 30MB of data. This is more or less what you'll find on other OSes, and obviously you don't want to burden your JS bundles with that much data!

The Intl API provides us with direct access to most of the OS' CLDR, which is awesome 😀

When it comes to dates and times, we've got two classes in there, the most well-known one being Intl.DateTimeFormat. The constructor has the following signature:

new Intl.DateTimeFormat([locales[, options]])

You can specify one or more locales, by decreasing priority. These are BCP47 strings with various levels of detail (going from simple generic language codes, such as 'fr' for generic French, to very detailed ones such as de-DE-u-co-phonebk, which is the phonebook sorting order variant for German). I would advise you always include a country variant (e.g. fr-FR for French in France), but going beyond that is usually overkill.

The system will use the first locale it can fully support based on the underlying CLDR.

Besides locales, you can specify options, and boy are there many options! We won't cover them all (this is a protip after all, not a comprehensive tutorial on that class), and in particular will set aside options about specific date and time segments (e.g. weekday, day, month, year, hours, minutes, seconds, etc.) or altering default locale behavior (hour cycles, etc.).

That said, we'll focus on very useful options for following a locale's formatting customs:

  • dateStyle for... the date part
  • timeStyle for... the time part
  • timeZone for, well... the time zone

#CaptainObvious

dateStyle and timeStyle

The dateStyle and timeStyle options allow 4 possible values: 'short', 'medium', 'long' and 'full', by increasing level of detail. All locales don't have that many variations, but the API will use whatever's appropriate.

Here are a few examples in 3 quite different languages:

// 🤯 Intl represent!

const when = new Date(2022, 9, 26, 9)

new Intl.DateTimeFormat('fr-FR', { dateStyle: 'short' }).format(when)
// => '26/10/2022'

new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'medium',
  timeStyle: 'short',
}).format(when)
// => '26 oct. 2022, 09:00'

new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'long',
  timeStyle: 'short',
}).format(when)
// => '26 octobre 2022 à 09:00'

new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'full',
  timeStyle: 'medium',
}).format(when)
// => 'mercredi 26 octobre 2022 à 09:00:00'

new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' }).format(when)
// => 'Mittwoch, 26. Oktober 2022'

new Intl.DateTimeFormat('ja-JP', {
  dateStyle: 'full',
  timeStyle: 'short',
}).format(when)
// => '2022年10月26日水曜日 9:00'

Not bad, eh?! 😉

Time zones

You can choose to display a time in a given time zone. The Date object being passed represents an absolute moment (even if you may have created it specifying a given time zone). Check this out:

// As you may well not be in an FR TZ, I'm forcing it...
const videoFR = new Date('2022-10-26 09:00:00 GMT+02:00')
const meetingFR = new Date('2022-11-04 09:00:00 GMT+01:00')

const valleyFormatter = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: 'America/Los_Angeles',
})

valleyFormatter.format(videoFR) // => Oct 26, 2022, 12:00 AM (9-hr offset, usual)
valleyFormatter.format(meetingFR) // => Nov 4, 2022, 1:00 AM (8-hr! The US are still on DST...)

Date shortcuts: toLocale…String()

If you often need to format dates and times, you are better off instantiating your formatter(s) just once and reuse them from then on. This is particularly true if you wrap them in a helper function: make sure to rely on a module-level singleton.

// ❌ Ouch, we re-create the same formatter every time!

export function formatTimelineStamp(stamp) {
  return new Intl.DateTimeFormat('fr-FR', {
    dateStyle: 'medium',
    timeStyle: 'short',
  }).format(stamp)
}

// ✅ Cool, we reuse a single formatter across calls

const timelineFormatter = new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'medium',
  timeStyle: 'short',
})

export function formatTimelineStamp(stamp) {
  return timelineFormatter.format(stamp)
}

On the other hand, should you just have a couple “one-shot” formatting needs, you may shorten your code a bit by going with the toLocaleString(), toLocaleDateString() and toLocaleTimeString() methods of Date. These allow the same arguments as the Intl.DateTimeFormat constructor (although narrowed to only date or time options when applicable):

// ⚠️ Only do this for one-off needs!

const when = new Date(2022, 9, 26, 9)

when.toLocaleString('fr-FR')
// => '26/10/2022 09:00:00'

when.toLocaleDateString('fr-FR')
// => ''26/10/2022'

when.toLocaleTimeString('fr-FR')
// => '09:00:00'

when.toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })
// => '26 octobre 2022 à 09:00 '

when.toLocaleDateString('fr-FR', { dateStyle: 'full' })
// => 'mercredi 26 octobre 2022'

when.toLocaleTimeString('fr-FR', { timeStyle: 'medium' })
// => '09:00:00'

Want to dive further?

You're in luck: we've got two complementary protips coming up about date/time ranges and time distances, scheduled for tomorrow and the next day!

Where can I use that?

Well, everywhere. Taking into account everything in this 3-protips series, you're good to go with any browser released in, say, the past 3 years, plus Node 13+ and Deno 1.8+. So go for it!

Protips galore!

We got tons of articles and screencasts, with a lot more to come. Also check out our kick-ass training courses 🔥!

Check out our video courses! 🖥

Our screencasts are the ideal, affordable complement to our tech articles and in-room training courses, tackling Git, JavaScript and other topics. Check out these high-quality, affordable courses that are specially crafted to take on your biggest pain points and roadblocks.