Implementing Temporal, the new date/time API for JavaScript (and Rust!)
Developing a JavaScript engine in Rust can seem like pretty daunting task to some. In order to demystify working on a feature and to go over what we've been working on implementing in Boa recently, we thought we'd write a post about implementing a JavaScript feature in Rust.
More specifically, this will be the first in a series of posts primarily about implementing the new date/time built-in: Temporal. We'll be going over general lessons and interesting design choices we've stumbled upon, as well as the crates supporting that implementation.
Why should you care? Well, we are not only implementing Temporal for JavaScript, but for Rust as well ... more on that in a bit.
First, an aside!
What even is Temporal?
Temporal is a modern API for handling date/time in a calendar and time zone aware manner that includes nine objects with over 200+ methods.
In JavaScript, Temporal is a global built-in namespace object that includes each of these nine built-ins:
Temporal.Now
Temporal.PlainDate
Temporal.PlainTime
Temporal.PlainDateTime
Temporal.ZonedDateTime
Temporal.Duration
Temporal.Instant
Temporal.PlainYearMonth
Temporal.PlainMonthDay
But to be honest, this post isn't meant to give an overview of Temporal and its general API. If Temporal is news to you and you are interested in learning more, feel free to check out the phenomenal MDN documentation.
Back on track
Being Boa a JavaScript engine / interpreter, developing a correct implementation of the ECMAScript specification is our raison d'être. This, in consequence, makes implementing Temporal one of our most important goals, since it represents roughly 7-8% of the current conformance test suite (~4000 of the ~50,000 tests).
When the PR of the first prototype of Temporal for Boa was submitted, a few things became evident:
- Date/Time is a complicated beast (duh)
- There's room for optimization and improvement
- This would be handy to have in Rust
So after the prototype was merged, we pulled it out of Boa's internal
builtins and externalized it into its own crate,
temporal_rs
, which landed behind an experimental
flag in Boa v0.18.
After over a year and a half of development, Boa now sits at a
conformance of about 90% for Temporal (and growing), with the entire
implementation being backed by temporal_rs
.
For its part, temporal_rs
is shaping up to be a proper Rust date/time
library that can be used to implement Temporal in a JavaScript engine,
and even support general date/time use cases.
Let's take a look at Temporal: it's JavaScript API, it's Rust API in
temporal_rs
, and how temporal_rs
supports implementing the
specification.
Important core differences
First, we need to talk about JavaScript values (JsValue
) for a bit.
This is functionally the core any
value type of JavaScript. A
JsValue
could be a number represented as a 64 bit floating point, a
string, a boolean, or an object. Not only is it an any
, but JsValue
is ultimately engine defined, with various implementations existing
across engines.
While this is handy for a dynamically typed language like JavaScript, it is not ideal for implementing deep language specifications where an object or string may need to be cloned. Furthermore, it's just not great for an API in a typed language like Rust.
To work around this, we routinely use FromStr
and a FiniteF64
custom
primitive to handle casting and constraining, respectively, which glues
dynamic types like JsValue
with a typed API.
For instance, in Boa, we heavily lean into using the below patterns:
// (Note: this is abridged for readability)
// FiniteF64 usage
let number: f64 = js_value.to_number(context)?;
let finite_f64: FiniteF64 = FiniteF64::try_from(number)?;
let year: i32 = finite_f64.as_integer_with_truncation::<i32>();
// FromStr usage with `get_option`
let options_obj: &JsObject = get_options_object(&js_value)?;
let overflow: Option<ArithmeticOverflow> = get_option::<ArithmeticOverflow>(
&options_obj,
js_string!("overflow"),
context
)?;
This is the core glue between Boa and the temporal_rs
API that we will
be going over below.
Implementing constructors
There are a variety of ways to construct a core component like
PlainDate
, and that stems from the core constructor for each of the
core components: new_with_overflow
.
impl PlainDate {
pub fn new_with_overflow(year: i32, month: u8, day: u8, calendar: Calendar, overflow: ArithmeticOverflow) -> Result<Self> {
// Create PlainDate
}
}
This function supports the baseline construction of Temporal builtins, which takes the usual year, month, day, alongside a calendar and also an overflow option to constrain or reject based on whether the provided values are in an expected range.
However, we can better express this in Rust with common try_
prefix
notation.
impl PlainDate {
pub fn new(year: i32, month: u8, day: u8, calendar: Calendar) -> Result<Self> {
Self::new_with_overflow(year, month, day, calendar, ArithmeticOverflow::Constrain)
}
pub fn try_new(year: i32, month: u8, day: u8, calendar: Calendar) -> Result<Self> {
Self::new_with_overflow(year, month, day, calendar, ArithmeticOverflow::Reject)
}
}
These three constructors, new_with_overflow
, try_new
, and new
, are
fairly flexible and provide full coverage of the Temporal specification.
For instance, take the below snippet:
const plainDate = new Temporal.PlainDate(2025, 6, 9);
This code can easily be translated to Rust as:
use temporal_rs::PlainDate;
let plain_date = PlainDate::try_new(2025, 6, 9, Calendar::default())?;
Furthermore, we actually learn some interesting things about the
JavaScript API from looking at the temporal_rs
API:
- The
Temporal.PlainDate
constructor can throw. - When the calendar is omitted, the default calendar is used (this will
default to the
iso8601
calendar)
Of course, if you somewhat prefer the brevity of the JavaScript API and
don't want to list the default Calendar
, temporal_rs
provides the
additional constructors new_iso
and try_new_iso
.
use temporal_rs::PlainDate;
let plain_date = PlainDate::try_new_iso(2025, 6, 9)?;
Interestingly enough, the _iso
constructors are mostly expressing a
part of the JavaScript API, just in native Rust. This is because in
JavaScript the _iso
constructors are assumed to exist due to resolving
an undefined
calendar to the default ISO calendar.
Let's discuss Now
Colonel Sandurz: Now. You're looking at now, sir. Everything that happens now, is happening now.
Dark Helmet: What happened to then?
Colonel Sandurz: We passed then.
Dark Helmet: When?
Colonel Sandurz: Just now. We're at now now.
Dark Helmet: Go back to then.
Colonel Sandurz: When?
Dark Helmet: Now.
Colonel Sandurz: Now?
Dark Helmet: Now.
Colonel Sandurz: I can't.
Dark Helmet: Why?
Colonel Sandurz: We missed it.
Dark Helmet: When?
Colonel Sandurz: Just now.
Dark Helmet: When will then be now?
-- Spaceballs, 1987
Temporal.Now
is an incredibly strange type, yet nevertheless
important. It is the object from which the current instant can be
measured and mapped into any of the Temporal components.
In JavaScript, this type has no [[Construct]]
or
[[Call]]
internal method, which is a fancy way to say
that Now has no constructor and cannot be called directly.
Instead, Now
is used primarily as a namespace for its methods.
And this was reflected in early adaptions of Now
, which looked more or
less like the below:
struct Now;
impl Now {
pub fn instant() -> Instant;
pub fn zoned_date_time_iso() -> ZonedDateTime;
}
Interestingly enough, the above implementation is incorrect, or at the very least not ideal.
Hidden in the specification steps for Now
are some very tricky steps
invoking the abstract operations: SystemTimeZoneIdentifier
and
SystemUtcEpochNanoseconds
. That's great, let's just use the usual
suspects SystemTime
and iana-time-zone
, merge it, and call it a day
on the implementation, right?
Except the core purpose of temporal_rs
is that it can be used in any
engine implementation, and accessing a system clock and system time zone
is sometimes difficult for engines that support targets like embedded
systems. Thus, this functionality must be delegated to the engine or
runtime ... somehow.
How did we end up implementing Now
if we have no access to the system
clock or time zone? Well ... a builder pattern of course!
#[derive(Default)]
pub struct NowBuilder {
clock: Option<EpochNanoseconds>,
zone: Option<TimeZone>,
}
impl NowBuilder {
pub fn with_system_nanoseconds(mut self, nanoseconds: EpochNanoseconds) -> Self {
self.clock = Some(nanoseconds);
self
}
pub fn with_system_zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
pub fn build(self) -> Now {
Now {
clock: self.clock,
zone: self.zone.unwrap_or_default(),
}
}
}
pub struct Now {
clock: Option<EpochNanoseconds>,
zone: TimeZone,
}
Once we've constructed Now
, then we are off to the races!
To show the NowBuilder
in action, in Boa, the implementation for
Temporal.Now.plainDateISO()
with the builder API is shown below:
impl Now {
// The `Temporal.Now.plainDateISO` used when building `Temporal.Now`.
fn plain_date_iso(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
let time_zone = args
.get_or_undefined(0)
.map(|v| to_temporal_timezone_identifier(v, context))
.transpose()?;
let now = build_now(context)?;
let pd = now.plain_date_iso_with_provider(time_zone, context.tz_provider())?;
create_temporal_date(pd, None, context).map(Into::into)
}
}
// A helper for building Now
fn build_now(context: &mut Context) -> JsResult<NowInner> {
Ok(NowBuilder::default()
.with_system_zone(system_time_zone()?)
.with_system_nanoseconds(system_nanoseconds(context)?)
.build())
}
The nice part about this approach is that it also allows a std
implementation that can be feature gated for general users that are not
concerned with no_std
.
// Available with the `sys` feature flag
use temporal_rs::Temporal;
let now = Temporal::now().instant();
Partial API
There's an interesting method on each of the Temporal built-ins that I'd
assume most people who have used Rust would be familiar with: from
.
But this isn't Rust's friendly From
trait. No, this from
is a
behemoth method that takes a JsValue
and automagically gives you back
the built-in that you'd like or throws. That's right! Give it a string,
give it a property bag, give it an instance of another Temporal
built-in; from
will figure it out for you!
Simple, right?
Folks, we're pleased to announce that temporal_rs
won't be supporting
that! ... or at least not in that shape.
Again, the goal of temporal_rs
is to implement the specification to
the highest possible degree of conformance, so when we couldn't provide
a direct translation of the specification's API, we made sure to provide
APIs that (hopefully) made the glue code between engines and
temporal_rs
much shorter.
To exemplify this, let's take a look at some valid uses of from
in
JavaScript to construct a PlainDate
.
// Create a `PlainDateTime`
const pdt = new Temporal.PlainDateTime(2025, 1, 1);
// We can use the `PlainDateTime` (`ZonedDateTime` / `PlainDate` are also options).
const pd_from_pdt = Temporal.PlainDate.from(pdt);
// We can use a string.
const pd_from_string = Temporal.PlainDate.from("2025-01-01");
// We can use a property bag.
const pd_from_property_bag = Temporal.PlainDate.from({
year: 2025,
month: 1,
day: 1,
});
If we look closely to the common usage of the method, it seems like all
that needs to be implemented by temporal_rs
is:
From<PlainDateTime>
: Easy.From<ZonedDateTime>
: Simple.FromStr
: Tricky but can be done.From<JsObject>
: ... ... oh. Did I mentionJsObject
, likeJsValue
, is engine defined as well?
Fortunately, this is where temporal_rs
's Partial API comes in.
It turns out that, while property bags in JavaScript can have various fields set, there is still a general shape for the fields that can be provided and validated in Temporal.
To support this in temporal_rs
, a "partial" component exists for each
of the components that can then be provided to that component's
from_partial
method.
With this, we have fully implemented support for the from
method in
temporal_rs
:
use core::str::FromStr;
use temporal_rs::{PlainDate, PlainDateTime, partial::PartialDate};
let pdt = PlainDateTime::try_new_iso(2025, 1, 1)?;
// We can use the `PlainDateTime` (`ZonedDateTime` / `PlainDate` are also options).
let pd_from_pdt = PlainDate::from(pdt);
// We can use a `str`.
let pd_from_string = PlainDate::from_str("2025-01-01")?;
// We can use a `PartialDate`.
let pd_from_partial = PlainDate::from_partial(
PartialDate::new()
.with_year(Some(2025))
.with_month(Some(1))
.with_day(Some(1))
);
NOTE: there may be updates to PartialDate
in the future (see
boa-dev/temporal #349
for more information).
Elephant in the room: time zones
So far we have not discussed time zones, and -- surprise! -- we aren't going to ... yet. It's not because they aren't super cool and interesting and everyone totally 100% loves them. No, time zones aren't in this post because they are still being polished and deserve an entire post of their own.
So stay tuned for our next post on implementing Temporal! The one where
we'll hopefully go over everyone's favorite subject, time zones; and
answer the question that some of you may have if you happen to take a
glance at temporal_rs
's docs or try out our no_std
support: what in
the world is a provider API?
Conclusion
In conclusion, we're implementing Temporal in Rust to support engine implementors as well as to have the API available in native Rust in general.
If you're interested in trying Temporal using Boa, you can use it in
Boa's CLI or enable it in boa_engine
with the experimental
flag.
Outside of Boa's implementation, temporal_rs
has implemented or
supports the implementation for a large portion of the Temporal's API in
native Rust. Furthermore, an overwhelming amount of the API can be
considered stable1 and is currently available in Boa with
only a few outstanding issues that may be considered breaking changes.
If you're interested in trying out temporal_rs
, feel free to add it to
your dependencies with the command:
cargo add temporal_rs
or by adding the below in the [dependencies]
section of your
Cargo.toml
:
temporal_rs = "0.0.9"
A FFI version of temporal is also available for C and C++ via
temporal_capi
.
Footnotes
-
A general note on API stability
While the majority of the APIs discussed above are expected to be mostly stable, Temporal is still a stage 3 proposal that is not fully accepted into the ECMAScript specification. Any normative change that may be made upstream in the ECMAScript or ECMA402 specification will also be reflected in
temporal_rs
.There are also a few outstanding issues with changes that may be reflected in the API.
- Duration's inner repr and related constructors.
ZonedDateTime.prototype.getTimeZoneTransition
implementation- TemporalError's inner repr
- Partial objects may need some adjustments to handle differences
between
from_partial
andwith
- Time zone provider's and the
TimeZoneProvider
trait are still largely unstable. Although, the provider APIs that use them are expected to be stable (spoilers!) - Era and month code are still be discussed in the intl-era-month-code proposal, so some calendars and calendar methods may have varying levels of support.
The above issues are considered blocking for a 0.1.0 release. ↩