diff --git a/blog/2025-06-15-temporal-impl-1.md b/blog/2025-06-15-temporal-impl-1.md new file mode 100644 index 00000000..d6ba33a7 --- /dev/null +++ b/blog/2025-06-15-temporal-impl-1.md @@ -0,0 +1,483 @@ +--- +layout: post +tags: [post] +title: + Implementing Temporal, the new date/time API for JavaScript (and + Rust!) +metadata: ["temporal", "temporal_rs", "boa", "date/time"] +description: + A blog post about the temporal_rs Rust crate that implements + JavaScript's Temporal API and how temporal_rs supports implementing + Temporal in JavaScript engines. +authors: boa-dev +--- + +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][mdn-temporal]. + +## 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: + +1. Date/Time is a complicated beast (duh) +2. There's room for optimization and improvement +3. 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`][temporal-rs-repo], 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: + +```rust +// (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::(); + +// FromStr usage with `get_option` +let options_obj: &JsObject = get_options_object(&js_value)?; +let overflow: Option = get_option::( + &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`. + +```rust +impl PlainDate { + pub fn new_with_overflow(year: i32, month: u8, day: u8, calendar: Calendar, overflow: ArithmeticOverflow) -> Result { + // 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. + +```rust +impl PlainDate { + pub fn new(year: i32, month: u8, day: u8, calendar: Calendar) -> Result { + Self::new_with_overflow(year, month, day, calendar, ArithmeticOverflow::Constrain) + } + + pub fn try_new(year: i32, month: u8, day: u8, calendar: Calendar) -> Result { + 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: + +```js +const plainDate = new Temporal.PlainDate(2025, 6, 9); +``` + +This code can easily be translated to Rust as: + +```rust +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: + +1. The `Temporal.PlainDate` constructor can throw. +2. 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`. + +```rust +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]]`][construct-link] or +[`[[Call]]`][call-link] 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: + +```rust +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! + +```rust +#[derive(Default)] +pub struct NowBuilder { + clock: Option, + zone: Option, +} + +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, + 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: + +```rust +impl Now { + // The `Temporal.Now.plainDateISO` used when building `Temporal.Now`. + fn plain_date_iso(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + 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 { + 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`. + +```rust + // 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`. + +```js +// 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`: Easy. +- `From`: Simple. +- `FromStr`: Tricky but can be done. +- `From`: ... ... oh. Did I mention `JsObject`, like + `JsValue`, 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`: + +```rust +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](https://github.com/boa-dev/temporal/issues/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 stable[^stability] 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: + +```bash +cargo add temporal_rs +``` + +or by adding the below in the `[dependencies]` section of your +`Cargo.toml`: + +```toml +temporal_rs = "0.0.9" +``` + +A FFI version of temporal is also available for C and C++ via +[`temporal_capi`][temporal-capi]. + +[^stability]: 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. + + 1. Duration's inner repr and related constructors. + 2. `ZonedDateTime.prototype.getTimeZoneTransition` implementation + 3. TemporalError's inner repr + 4. Partial objects may need some adjustments to handle differences + between `from_partial` and `with` + 5. 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!) + 6. 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. + +[mdn-temporal]: + https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal +[temporal-rs-repo]: https://github.com/boa-dev/temporal +[construct-link]: + https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-ecmascript-function-objects-construct-argumentslist-newtarget +[call-link]: + https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-ecmascript-function-objects-call-thisargument-argumentslist +[boa-test262]: https://test262.fyi/#|boa +[temporal-capi]: https://crates.io/crates/temporal_capi