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.
Hey there! This is Taro from the future.
Very interesting progress has been made since I wrote this article, and the API is now really close to being production-ready.
Check out The Temporal API is Moving Forward!!
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 # 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.
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' // 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 })
hours23.toString()
> '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:
- 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!
- Yes, you can naively add milliseconds times unit, but that won't be accurate in a ton of cases. And forget about dealing with time zones, DST, etc.
- See commit
- First tc39 proposal commit
- See moz.configure and comment about it in the tracking issue.
- Counting commits and added/modified LoC as seen in the repo's insights/contributors, which might not be the most accurate metric, but I'll take it.
- Some bocoup consultancy implemented a script to clone the 262 test suite into WebKit's repo and keep track of which tests are passing/failing. See New Test262 Import and Runner in WebKit.