Temporal API is Awesome

Dates in JS suck. Well, they suck in all languages, really. It's surprisingly hard to get right.

The native Date is super limited. Sure, you can new Date('2015-10-21T01:22:00.000Z') and date.toISOString(), maybe dateA < dateB, but that's pretty much it. Need to add minutes, hours, or whatever to a date, check how many days there are until X date, etc? Good luck with that1.

So a bunch of libraries were developed to solve this issue. MomentJS was the best way back when. In 20202 the project added a project status page, declaring MomentJS a legacy, deprecated project, and suggesting alternatives. Luxon was one of them, which actually was —and is— excellent.

But wouldn't it be nice to just have a standard solution, provided out-of-the-box by browsers, NodeJS, Deno, Bun and whatever new JS runtime gets released next week?


Temporal API

This is it. The standard we've all been waiting for.

I played around with the Temporal API last weekend and gosh it's beautiful. So much that I'm willing to go to prod with a polyfill that says "don't use this in prod" in its README.

The API just makes sense:

Work on this proposal started in early 20173, and reached stage 3 at the March 2021. Stage 3 is a Good Thing™ because, even though stage 4 is the definite "finished", stage 3 is when they become stable and vendors start implementing and making them available behind feature flags. TypeScript, too, usually implements proposals as soon as they reach stage 3.

But Taro it's been over 2 years omg why is it not in stage 4 it must have been abandoned it'll never get adopted js hates us and tc39 zuckzz

Jeez. Calm down. You know what? It landed in Firefox just a few weeks ago, it's been available in Safari for a while and is actively being worked on in Chrome. It sits behind feature flags — --useTemporal in Safari, --with-temporal-api build-time flag in Firefox4 and --harmony-temporal in Chrome (anything that uses v8).

Press Install to Play!

Let give this beauty a try. We can do so by either enabling a feature flag or using a polyfill.

We can enable the feature in v8-based runtimes with the --harmony-temporal flag:

google-chrome --js-flags="--harmony-temporal"
node --harmony-temporal # use node --v8-options to list all v8 supported flags
deno repl --v8-flags=--harmony-temporal

In Firefox it's a build-time flag, not a runtime one, so you'd have to build the binary yourself. Nightly releases should have most flags turned on by default, but I couldn't get the Temporal API to work in it yet. I haven't tried Safari yet since it's not available for Linux and I can't be bothered to go grab my old MacBook it turns out my old MacBook doesn't get the new versions of the OS and Safari and the Safari Nightly/Dev releases, so I have no way of testing it.

One way or the other, the way to go today is the polyfill: npm install @js-temporal/polyfill.

Next we have to import it: import { Temporal } from '@js-temporal/polyfill';. This is necessary with the polyfill, but won't be once it's supported natively, in the same way we use fetch or Math without importing them.

Heads up: in the following sections I'll provide runnable scripts that let you test the Temporal API right here in your browser.

These don't use any 3rd party libraries — I decided to completely avoid loading any 3rd party scripts in this site (and your browser). I don't like not knowing what they are doing behind the scenes, and they always seem to add a bunch of tracking cookies. That's not the experience I want to provide to my readers.

Only exception is the temporal api polyfill — which is loaded only if the native api isn't available. This is loaded from here.

Let's start simple:

export function run() {
  const now = Temporal.Now.instant().toString()
  return now

If all's good, you should see the current date displayed next to the run button. Nothing we can't do with the good ol' Date, though. Let's spice things up:

export function run() {
  const now = Temporal.Now.zonedDateTimeISO()
  const startedAt = now.subtract({ hours: 2 }).toLocaleString('en-us')
  const endsAt = now.add({ hours: 2 }).toLocaleString('en-us')
  return `Event started at ${startedAt} and will end at ${endsAt}.`

That's a bit more interesting. The human-readable date is a bit unwieldy though — for an event that lasts less than one day, we can do better:

export function run() {
  const now = Temporal.Now.zonedDateTimeISO()
  const format = { weekday: 'long', hour: 'numeric', minute: 'numeric' }
  const startedAt = now.subtract({ hours: 2 }).toLocaleString(navigator.language, format)
  const endsAt = now.add({ hours: 2 }).toLocaleString(navigator.language, format)
  return `Event started at ${startedAt} and will end at ${endsAt}.`

This is all the information a user needs, while still being accurate. Try running the script with larger durations — it'll correctly change the day name. I also went ahead and replaced the hardcoded en-US locale with navigator.language.

We can still do better, though:

export function run(hoursSince = 2, hoursUntil = 2) {
  const now = Temporal.Now.zonedDateTimeISO()
  const startedAt = now.subtract({ hours: hoursSince })
  const endsAt = now.add({ hours: hoursUntil })
  const startedAtDaysSince = now.withPlainTime().since(startedAt.withPlainTime()).round({ smallestUnit: 'day', largestUnit: 'day' }).days
  const endsAtDaysUntil = now.withPlainTime().until(endsAt.withPlainTime()).round({ smallestUnit: 'day', largestUnit: 'day' }).days

  const rtf1 = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
  const relativeDayStarts = rtf1.format(-startedAtDaysSince, 'day')
  const relativeDayEnds = rtf1.format(endsAtDaysUntil, 'day')
  const format = { hour: 'numeric', minute: 'numeric' }
  return `Event started ${relativeDayStarts} at ${startedAt.toLocaleString(navigator.language, format)} and will end ${relativeDayEnds} at ${endsAt.toLocaleString(navigator.language, format)}`

Now the output should show something like Event started today at 3:46 PM and will end today at 7:46 PM.

If we play with the hours, we'll see it properly adjust to yesterday or tomorrow:

// now = 5.28pm

run(2, 12)
> Event started today at 3:28 PM and will end tomorrow at 5:28 AM

run(12, 2)
> Event started today at 5:28 AM and will end today at 7:28 PM

run(20, 2)
> Event started yesterday at 9:28 PM and will end today at 7:28 PM

run(80, 2)
> Event started 3 days ago at 9:28 AM and will end today at 7:28 PM

Let's unpack this:

We're using zonedDateTimeISO because it's the only format that has ALL date information. There's good info about it in the docs, but tl;rd use this one if you need to do any math on the date.

We start by storing now and adding/substracting a couple of hours to/from it. Nothing crazy here. The next part is way more interesting:

const startedAtDaysSince = now.withPlainTime().since(startedAt.withPlainTime()).round({ smallestUnit: 'day', largestUnit: 'day' }).days
const endsAtDaysUntil = now.withPlainTime().until(endsAt.withPlainTime()).round({ smallestUnit: 'day', largestUnit: 'day' }).days

These two lines are basically the same, switching until(end time) for since (start time). We're calling withPlainTime() that allows us to set individual fields, like withPlainTime({ hours: 17 }) — but pass no argument to it, which defaults to 00:00:00, basically clearing out the time, leaving only the date portion.

> '2023-09-19T19:22:40.195128985-03:00[America/Buenos_Aires]'
> '2023-09-19T00:00:00-03:00[America/Buenos_Aires]'

We do that with both now and the endsAt / startedAt before calculating the duration between them, making it so the duration will be a number of days based on calendar day of the month — otherwise we'd get back our original input, the one we're initially passing to .add/ .substract.

The until and since functions return a Duration. Durations are not balanced by default, you need to call round to balance them. "Balance" here being a fancy word for this:

const now = Temporal.Now.zonedDateTimeISO()

const hours27 = Temporal.Duration.from({ hours: 27 })

> 'PT27H' // 27 hours, zero days
hours27.round({ smallestUnit: 'hour' }).toString()
> 'PT27H' // same thing, still 27 hours, zero days
hours27.round({ smallestUnit: 'hour', largestUnit: 'day' }).toString()
'P1DT3H' // 1 day, 3 hours
hours27.round({ smallestUnit: 'day', largestUnit: 'day' }).toString()
'P1D' // 1 day, which is what we want, but...

const hours23 = Temporal.Duration.from({ hours: 23 })

> 'PT23H'
hours23.round({ smallestUnit: 'day', largestUnit: 'day' }).toString()
> 'P1D' // 23 hours round up to 1 day, rather than down to 0 days
hours23.round({ smallestUnit: 'day', largestUnit: 'day', roundingMode: 'halfExpand' }).toString()
> 'P1D' // if we pass no roundingMode, it defaults to `halfExpand`. what we want here is `trunc`.
hours23.round({ smallestUnit: 'day', largestUnit: 'day', roundingMode: 'trunc' }).toString()
> 'PT0S' // boom! that's what we want

Just for completeness' sake, in our case, using toPlainDate rather than withPlainTime would have worked, too:

const startedAtDaysSince = now.toPlainDate().since(startedAt.toPlainDate()).days

Now that we have the time duration between now and start/end dates rounded to whole days, we just need to grab the .days property and pass it to the awesome Intl.RelativeTimeFormat:

const rtf1 = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const relativeDayStarts = rtf1.format(-startedAtDaysSince, 'day') // could also use duration.negated().days ¯\_(ツ)_/¯
const relativeDayEnds = rtf1.format(endsAtDaysUntil, 'day')

This is so freaking cool. You can surely do this with existing libraries, but soon we will be able to without any libraries at all. And the API is so elegant!

There's so much more we can do with the Temporal API, but it's enough for one day. Check out the docs for more stuff — they are super friendly!

Caveat Emptor

So, should we use this in production? Probably not. caniuse.com/temporal is 100% red, obviously. The polyfill and the spec docs state pretty clearly "not production ready". The polyfill npm package has ~21k weekly downloads, Luxon has 1.5m. And Moment.js has 5.3m wtf. It's been deprecated for years. Anyways.

On the other hand, someone needs to test this before this can go live. If you can afford some risk, encapsulating it and limiting the maximum damage it could do, while having a good test suite, I think it's totally worth it. Some guidelines to manage risk:

State of the Union API

If you're as excited as me, here's the current status of the proposal and implementations, plus some links to keep track of progress.

TC39 Proposal

The main place where this lives is its GitHub repo.

There are two tracking tickets: the tracking issue for syncing with IETF standardization work and the final normative spec text plan.

The former seems to be the one blocking unflagging in browsers and other implementors:

Although Temporal is currently Stage 3 in order to gather feedback from implementers, implementers must ship Temporal support behind a feature flag until we finish work with IETF to standardize the string formats used for calendar and time zone annotations

This is currently waiting for and blocked by IETF approval of Date and Time on the Internet: Timestamps with additional information, a proposed update to the 20-years-old standards-track RFC 3339: Date and Time on the Internet: Timestamps.

The document has had 3 very recent reviews:

More recently, someone has asked whether the TC39 Temporal API proposal could be split, so it's no longer blocked by the RFC proposal. Philip Chimento, champion of the proposal and main contributor5, replied:

Splitting up the proposal to go ahead with the parts that don't depend on the string format; I have been thinking about this as well. I suspect it would require a considerable communications effort in the committee, which would take away from the other things we have to do to keep the proposal moving towards the next stage. You could say that our process should be able to deal with that more easily, and you might be right, but this is the situation.

The latter GitHub issue, as it is right now, seems to only require merging a few open PRs

As of 2023-07-12, we have resolved all known discussions that might result in normative changes, and all of the normative changes have been presented to TC39 and received consensus. This issue is a checklist and plan for merging those normative PRs into the spec text, so that observers can see the status at a glance.

After completing this checklist, barring fixes for bugs found during implementation, the spec should be in its final normative form, representing exactly what needs to be implemented.

Subscribing to notifications for these GitHub issues is probably the best way to stay informed about the proposal's progress towards Stage 4. Second to that would be the sedate mailing list.

There are also the Stage 3.5 and Stage 4 milestones, but we can't subscribe to that.

Also interesting: the Temporal API 262 tests.


From the SpiderMonkey Newsletter (Firefox 116-117), from Aug 7, 2023:

We’ve implemented the Temporal proposal.

See also the "Implement the Temporal proposal" Bugzilla ticket, now closed with RESOLVED FIXED.

While we're at it, Temporal is implemented in src/builtin/temporal and its 262 tests live in src/tests/test262/built-ins/Temporal. I could not find test results, though.

You can also access the repo through the read-only GitHub mirror.

Google Chrome

There's a chrome status page for the Temporal API, but it was last updated almost a year ago. Not particularly exciting. But there's also a tracking bug, which does have more recent activity: Issue 11544: Implement the Temporal proposal.

Looking at the code, all temporal-api-related code I can find was last modified, at best, in 2022-11. See the git history of, for example, js-temporal-objects.h, js-temporal-objects.cc, js-temporal-objects-inl.h, builtins-temporal.cc, js-temporal-objects.tq and test/mjsunit/temporal.

It seems most or all of the 262 Temporal tests are failing: test262.status :(


Tracking implementation status in Safari is a bit tricky. There seems to be a catch-all Implement Temporal ticket, but it's last been modified in 2022-01, even though there are more recent commits.

The most recent Safari Technology Preview release notes that mention progress on Temporal API are 156, 155, 154, 153 — all from 2022.

Looking into the source code the most recent relevant commit I could find is 1490a5c, from 2023-03.

The features.json marks Temporal as "In Development", and test262/config.yml seems to be skipping many/most TemporalAPI tests, expectation.yml dedicating some 800 lines to expected Temporal API test failures6.

The feature flag seems to be defined in runtime/OptionsList.h#L580 and consumed in JSGlobalObject.cpp#L1334. Let me know if you manage to test it :)

Final Words

As always, I got immensely side-tracked while writing this article. I originally wanted to show some examples and nothing more, but I wound up going code-spelunking into the depths of the formal proposal and implementations in the main three browsers.

I also built my own runnable script widget, because I think loading 3rd party libraries like CodePen or RunKit without your consent is not cool, and rather than asking for consent I decided to avoid the problem entirely by creating my own implementation.

I'm really happy with the result, and hope you find all this information useful, interesting and/or entertaining. Check out my newsletter if you did :)

Have a nice day and see you in the next one!

A newsletter for programmers

Yo! This is Taro. I've been doing JavaScript for years and TypeScript for years. I have experience with many programming languages, libraries, frameworks; both backend and frontend, and in a few company roles/positions.

I learned a few things over the years. Some took more effort than I wish they had. My goal with this blog and newsletter is to help frontend and backend developers by sharing what I learned in a friendlier, more accessible and thorough manner.

I write about cool and new JavaScript, TypeScript and CSS features, architecture, the human side of working in IT, my experience and software-related things I enjoy in general.

Subscribe to my newsletter to receive notifications when I publish new articles, as well as some newsletter-exclusive content.

No spam. Unsubscribe at any time. I'll never share your details with anyone. 1 email a week at most.

You have subscribed to Taro's newsletter
The server blew up. I'll go get my fire extinguisher — please check back in 5.