· 7 min read ·

From Userland to Language: The Design Process Behind Temporal

Source: hackernews

The story of Temporal usually gets told as a technology story: Date had these specific problems, so TC39 designed these types to fix them. That framing is accurate but incomplete. What actually happened was that the authors of the most popular JavaScript date library concluded they had hit a fundamental ceiling, became TC39 proposal champions, and then did something unusual with the proposal process itself. Understanding what they changed, and why, explains both the API and why it took so long.

The Specific Ceiling Moment.js Hit

Moment.js was not a bad library. It represented years of careful engineering around a genuinely broken primitive. But it wrapped Date, and Date’s problems were not surface problems.

Consider what Moment could not provide regardless of implementation quality. In JavaScript, there is no native type for a calendar date with no time component. Moment could give you moment().startOf('day'), which produces midnight local time, but that is not the same thing. Serialize a midnight-local-time value across timezones and read it back, and you can end up on the wrong day. Moment had no way to enforce that a date-only value was being treated as date-only, because the language had no such concept.

Timezone arithmetic in moment-timezone bundled the IANA timezone database and improved substantially over raw Date, but at the bottom of every DST-aware calculation was a conversion through UTC milliseconds via Date. The conversion points were where subtle errors crept in, particularly around DST transitions and the ambiguous hours when clocks fall back.

Moment’s duration type stored a millisecond count and expressed months only approximately. “One month from March 31” was always going to produce an approximation because the real answer depends on calendar arithmetic that requires a reference point. Moment picked a silent default. The silent default was sometimes wrong.

Maggie Pint and Matt Johnson-Pint, key figures in Moment.js’s development, concluded these were architectural problems with Date itself. They became TC39 Temporal proposal champions specifically because they understood what userland could not fix. The Moment.js documentation now explicitly recommends Temporal as the future path, which is not a coincidence.

A Polyfill as a Research Instrument

Most TC39 proposals follow a similar trajectory: spec text is written, champions gather committee consensus, and only after the proposal ships in browsers does the broader developer community have real experience with the API. By then, breaking changes are expensive. If the API is awkward in ways that only emerge in production use, those awkwardnesses tend to stay.

Temporal tried something different. Bloomberg sponsored Igalia, a web standards consultancy, to write both the formal specification text and a production-quality polyfill. Philip Chimento and Ujjwal Sharma at Igalia did the bulk of this work. The resulting @js-temporal/polyfill package was not a prototype. It was a complete implementation designed to pass the TC39 test262 suite, available on npm for developers to use in real applications before any JavaScript engine shipped native support:

npm install @js-temporal/polyfill
import { Temporal } from '@js-temporal/polyfill';

// Identical API to the native implementation
const today = Temporal.Now.plainDateISO();

This shifted the feedback loop. Developers built actual production code against the polyfill while the spec was still in Stage 3 and still changeable. The problems they encountered fed back into the proposal. Multiple breaking changes happened before Stage 3 as a result, which is unusual in TC39 work.

What Changed as a Result

The early Temporal design modeled TimeZone and Calendar as mutable, class-based objects. The intent was to allow extension: if you had a custom financial calendar or a non-standard timezone definition, you could subclass the provided types. This approach had a specific problem in JavaScript’s object model.

In a language with mutable prototypes, allowing subclassable calendar objects means a dependency in your node_modules could intercept and modify date arithmetic. If a library injects a malicious or buggy Calendar subclass during module initialization, every date operation downstream could be affected without any obvious indication. In an ecosystem where the average application depends on hundreds of packages with full prototype access, this is a real concern.

The redesign moved to a protocol-based model. Rather than subclassing Temporal.TimeZone, you provide an object that implements the required methods. Duck typing instead of inheritance. This preserves extensibility without exposing the prototype chain to calendar manipulation.

Temporal.PlainYearMonth and Temporal.PlainMonthDay were also added late in the process in response to real use cases surfaced by polyfill users. Credit card expiration dates require year-and-month with no day. Annual recurring events like holidays require month-and-day with no year. The early API had no way to represent these without carrying unwanted extra fields; developers hit this in production and the types were added.

const expiry = Temporal.PlainYearMonth.from({ year: 2028, month: 6 });
const birthday = Temporal.PlainMonthDay.from({ month: 7, day: 4 });

// These types could not be represented cleanly in the early Temporal design
// Adding them required late-stage spec changes — possible because the polyfill
// revealed the gap before the API was frozen

The relativeTo Controversy

The most contested late-stage decision was whether duration arithmetic that depends on calendar knowledge should throw an error or silently pick a default.

The problem is real: when you try to round a Temporal.Duration containing months to days without specifying a starting point, the conversion is undefined. A month is not a fixed number of days. Temporal requires a relativeTo reference date:

const d = Temporal.Duration.from({ months: 1, days: 15 });

// Throws: RangeError: a starting point is required for this operation
d.round({ largestUnit: 'days' });

// Works
d.round({
  largestUnit: 'days',
  relativeTo: Temporal.PlainDate.from('2025-01-31')
});

Some TC39 members argued for defaulting to 30 days per month, which is what Moment and virtually every other library does. The counter-argument was that 30 days per month is always wrong in some context, and hiding the error produces subtly incorrect results that accumulate and are hard to trace. The RangeError at least identifies where to look.

Developers who used the polyfill in production initially found this error jarring. After working through it, most concluded it was correct. The friction of seeing the error once and fixing it was preferable to diagnosing a wrong answer that had been silently accumulating in scheduling data for months.

The final design chose the error. This is a consistent pattern throughout Temporal: surface ambiguity explicitly rather than silently resolving it incorrectly. Every place where Date picked a silent default, Temporal either provides a clearly-documented explicit default or requires a choice.

// DST gap handling — 2:30 AM doesn't exist during the spring-forward
// Temporal requires you to say what you mean
Temporal.ZonedDateTime.from(
  '2025-03-09T02:30:00[America/New_York]',
  { disambiguation: 'earlier' }  // Use the time before the gap
);
// Also: 'later', 'compatible' (matches Date's old behavior), 'reject'

// Month overflow
const jan31 = Temporal.PlainDate.from('2025-01-31');
jan31.add({ months: 1 }, { overflow: 'constrain' }); // 2025-02-28
jan31.add({ months: 1 }, { overflow: 'reject' });    // RangeError

The DST Arithmetic That Actually Works

The final ZonedDateTime type captures something that Date could not express at all: the distinction between adding a fixed number of hours versus adding a calendar day.

// 1:30 AM the night before DST spring-forward in New York
const before = Temporal.ZonedDateTime.from('2025-03-09T01:30:00[America/New_York]');

// These two operations produce different results across the DST boundary
const plus24h = before.add({ hours: 24 });
// 2025-03-10T02:30:00-04:00[America/New_York]  (24 literal hours elapsed)

const plusDay = before.add({ days: 1 });
// 2025-03-10T01:30:00-04:00[America/New_York]  (same wall-clock time)

Date had no mechanism to express which of these you meant. Every library that sat on top of Date inherited the same limitation. ZonedDateTime gives you both and requires you to choose.

Browser Support and the Practical Path

Firefox shipped Temporal natively in version 139. V8 has landed the implementation, making it available in current Chrome and Node.js versions. For older environments, the polyfill passes the full test262 suite and works in production. It runs to roughly 380KB minified because it must bundle the IANA timezone database and calendar computation tables, but that overhead disappears once native support is available.

For interoperability with existing code, the conversion path is straightforward:

// Legacy Date → Temporal
const legacy = new Date();
const instant = Temporal.Instant.fromEpochMilliseconds(legacy.getTime());
const zdt = instant.toZonedDateTimeISO('America/New_York');

// Works directly with Intl.DateTimeFormat
zdt.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' });
// "Tuesday, March 11, 2025 at 9:30 AM"

What the Methodology Produced

The polyfill-first approach, combined with Bloomberg’s willingness to fund sustained Igalia engineering over multiple years, produced something unusual: a large, complex standards proposal that arrived at Stage 3 with real production testing behind it. The breaking changes happened before standardization, not after. The awkward API corners were discovered by actual developers before the design was frozen.

This stands in contrast to the more common trajectory where proposals advance quickly and problems surface only after wide engine deployment. The Temporal process is worth examining as a model for other proposals in complex domains, not just for what the API does but for how it was built.

The result is an API that compares favorably with Noda Time in C# and java.time in Java, both of which went through their own multi-year journeys from broken primitives to correct foundations. Java 8’s JSR-310, designed by the Joda-Time author, took a similar approach: build the right type taxonomy first and accept the resulting complexity. Temporal’s TC39 specification runs to roughly 200 pages of formal ECMA-262 text. The Moment.js authors were right that the problem required language-level work. The Bloomberg account of that journey is worth reading on its own — what it documents is not just an API, but a decade of getting the design right before it could no longer be changed.

Was this interesting?