The Temporal API Is Awesome

2023-09-2716:31158taro.codes

Wednesday, August 23, 2023 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…

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?

Enter...

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:

  • Everything's immutable.
  • It provides Date, Time, DateTime, Duration and TimeZone objects.
  • It also provides a Calendar object, which we generally won't need but hey let's cover all cases once and for all.
  • All objects and functions have different versions of them, fitting all use cases: Instant, Plain and Zoned. We'll get to those in a minute — just hold on to your seats.
  • You don't need to import it. Like Math, it's just globally available.

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

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:

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.

Temporal

.

Now

.

zonedDateTimeISO

(

)

.

toString

(

)


>

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


Temporal

.

Now

.

zonedDateTimeISO

(

)

.

withPlainTime

(

)

.

toString

(

)


>

'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 })

hours27.toString()


>

'PT27H'


hours27

.

round

(

{

smallestUnit

:

'hour'

}

)

.

toString

(

)


>

'PT27H'


hours27

.

round

(

{

smallestUnit

:

'hour'

,

largestUnit

:

'day'

}

)

.

toString

(

)


'P1DT3H'


hours27

.

round

(

{

smallestUnit

:

'day'

,

largestUnit

:

'day'

}

)

.

toString

(

)


'P1D'

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

hours23.toString()


>

'PT23H'


hours23

.

round

(

{

smallestUnit

:

'day'

,

largestUnit

:

'day'

}

)

.

toString

(

)


>

'P1D'


hours23

.

round

(

{

smallestUnit

:

'day'

,

largestUnit

:

'day'

,

roundingMode

:

'halfExpand'

}

)

.

toString

(

)


>

'P1D'


hours23

.

round

(

{

smallestUnit

:

'day'

,

largestUnit

:

'day'

,

roundingMode

:

'trunc'

}

)

.

toString

(

)


>

'PT0S'

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'

)


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:

  • Using it in a single feature, not the whole application, would greatly lower risk.
  • Using it exclusively in the frontend, not sending dates handled with the polyfill to a backend, should be pretty low-risk. Unless your app is used by surgeons and it displays exact date and times they should be in the operating room. Stuff like that. You get the drill.
  • Using it in the backend as a predicate to drive business logic that, on false positives/negatives, would not taint any data, might be acceptable to specific use cases. Obviously don't go using Temporal API on a function that determines whether someone goes to jail.
  • Using it in an event-sourced system, in which you could easily retrace your steps if a bug is found, might be OK.
  • Using it in the backend, storing or permanently overwriting data with the output of some Temporal API function would probably be a bad idea 🙅
  • Do write all date arithmetic in pure functions and write a bunch of tests for them. Pay attention to edge cases. You should be doing this regardless of what date/time lib you use, but it's worth reminding.

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:

  • A general review, considering the document ready with issues.
  • A review by the Operational Directorate Reviews groups, which considers that it formally has issues, but in practice isn't sure that's the right review result.
  • One focusing on security and privacy, considering it ready.

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.

Firefox

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 :(

Safari

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!


Read the original article

Comments

  • By tarokun-io 2023-09-2716:31

    Hey everyone! Earlier this week I published this article on the Temporal API (it really is awesome).

    It's only the second time I dare share a link to an article I wrote. I find it intimidating, honestly.

    I put a ton of effort into writing the article (and creating the no-dependencies runnable-script widget). Would love your feedback / constructive criticism :)

  • By koromak 2023-09-2718:474 reply

    I'm currently procrastinating my task of migrating an entire frontend from MomentJS to date-fns. I hate dates, and I hate this work. I would be overjoyed if we get a standard.

    Funnily enough, date-fns-tz is MUCH worse than moment-timezone, so much so that I'm regretting making the decision to migrate. It works by fundamentally altering the Unix timestamp of the underlying date, so that when you format() it, the times appear correct. But try to actually pull a unix seconds out of it and you're screwed.

    I should have stuck with the larger bundle size. Aside from Moment being mutable and non-tree-shakeable, the api is better in every way if you care about fixed, non-local timezones.

    • By MrJohz 2023-09-2719:071 reply

      This is the fundamental limitation of the current built-in API, and the reason that Temporal is necessary: a Date object has no concept of timezones, and there is no way to inject that into it. It can kind of comprehend that UTC exists, and you can pass dates into the relevant Intl API and get dates formatted in the right timezone, but doing maths on different timezones is nearly impossible to do correctly.

      This is the biggest flaw with date-fns - it's great if you're only working with naive timestamps where timezones aren't an issue, but there's fundamentally no way to get things to work correctly when timezones do show up.

      • By TheCoelacanth 2023-09-2719:41

        I think Date is worse that something that has no concept of timezones. It has just enough of a concept of timezones to mess things up, but not enough to implement anything correctly.

    • By tarokun-io 2023-09-2719:02

      Yikes, that doesn't sound like fun. Dates seem inoffensive at first sight but turn out to be really hard to do.

      Have you tried Luxon?

      If your frontend isn't completely critical, you might be ok with the temporal-api polyfill, too. It's a joy to use.

    • By koromak 2023-09-2718:541 reply

      Also: This seems like such an obvious quality of life update. Is there a reason Chrome is dragging their feet? What about bun/deno? This could be a huge differentiator for them.

      • By tarokun-io 2023-09-2719:01

        100%. I've tested deno with the feature flag — all it needs is the upstream v8 to unflag it

        deno repl --v8-flags=--harmony-temporal

        I couldn't test it in bun, but it should be the same thing, except that it depends on JavaScriptCore instead of v8. But basically nodejs, deno and bun don't really need to do anything — just wait for v8 and JSCore.

        Browsers are waiting for the proposal to signal that it's ready to be unflagged. Right now it states it must be behind a flag.

    • By runescimitar 2023-09-2719:30

      Have you tried Luxon?

HackerNews