· 6 min read ·

The Type System That Took Nine Years: Inside JavaScript's Temporal API

Source: hackernews

The Date object in JavaScript has one problem that gets mentioned constantly: getMonth() returns 0 for January and 11 for December, inherited from java.util.Date in Brendan Eich’s original ten-day implementation. That particular quirk, while annoying, is fixable with a comment or a wrapper. The actual problem with Date is architectural, and it took nine years and significant effort from Bloomberg’s engineering team to articulate and fix it properly.

Bloomberg’s blog post on Temporal describes the journey from the perspective of engineers who needed JavaScript dates to be correct in financial software. Bloomberg’s Philipp Dunkel became one of the proposal’s champions at TC39, not because the getMonth() bug annoyed him, but because Date fundamentally cannot represent what financial systems need to represent.

What Date Actually Is

A Date object stores a single integer: milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). Everything else, the year, month, day, hour, minute, is computed on the fly from that integer using either UTC rules or the local system timezone.

This means a Date is simultaneously two things: an absolute point in time (the epoch offset) and a civil time representation (a date and clock time in some timezone). These are different concepts, and conflating them causes real bugs.

Consider scheduling a meeting for “9 AM New York time, every day for the next week.” You cannot do this correctly with Date alone. You can store the epoch milliseconds for 9 AM New York time today, but adding 86,400,000 milliseconds (24 hours) to that number does not give you 9 AM tomorrow in New York. March 10 in New York (the DST spring-forward day) is only 23 hours long; your 9 AM becomes 10 AM.

The library ecosystem built around this limitation. Moment.js (launched 2011) and its successor Luxon wrapped Date with timezone handling; date-fns provided pure functions that at least made operations explicit; day.js offered a lighter Moment.js-compatible API. All of them worked around the architectural problem rather than solving it. Moment.js is now in maintenance mode, explicitly recommending users migrate to something else.

Eleven Types Is the Correct Answer

Temporal’s answer is a type hierarchy where each type carries exactly the information its name implies, no more and no less.

Temporal.Instant represents a point in absolute time, like a Unix timestamp with nanosecond precision. No timezone, no calendar, no concept of what day it is, just a moment.

Temporal.ZonedDateTime is the full type: an absolute time plus a wall-clock date and time plus an IANA timezone identifier. It knows that 2024-03-10T02:30:00[America/New_York] is the point when New York clocks were about to spring forward, and it handles arithmetic accordingly. Adding one calendar day gives you the next calendar day, regardless of whether that day was 23, 24, or 25 hours long.

Temporal.PlainDate, Temporal.PlainTime, and Temporal.PlainDateTime exist for situations where a timezone is not part of the data model. A birthday is a PlainDate: it does not exist at a particular moment in absolute time, it exists on a calendar day. Store a birthday as a PlainDate and you will never accidentally shift it by eight hours when a user in Tokyo views your application.

Temporal.Duration handles the case that Date has no answer for: calendar-relative spans of time. Adding one month to January 31 should give February 29 in a leap year, or February 28 otherwise. In milliseconds, you cannot represent “one month” because a month is not a fixed number of seconds. Duration carries { months: 1 } as a distinct component, and the arithmetic resolves it calendar-correctly.

The remaining types (PlainYearMonth, PlainMonthDay) handle specific data modeling needs like fiscal quarters or annual recurring events where the year varies. Every one of these types represents a genuinely distinct concept, and merging any two would require choosing one set of constraints at the cost of the other.

The Code That Changes

The difference in practice is clearest in arithmetic:

// Legacy Date: adding one month to January 31
const d = new Date(2024, 0, 31);
d.setMonth(d.getMonth() + 1);
console.log(d.toDateString()); // "Sat Mar 02 2024" — silently wrong

// Temporal: adding one month to January 31
const jan31 = Temporal.PlainDate.from('2024-01-31');
const result = jan31.add({ months: 1 });
console.log(result.toString()); // "2024-02-29" — correct for 2024 leap year

Parsing is strict in a useful way. Temporal requires unambiguous input:

Temporal.PlainDate.from('2024-03-15'); // works
Temporal.ZonedDateTime.from('2024-03-15T14:30:00[America/New_York]'); // works

// The bracket notation is the IANA timezone ID.
// Both the UTC offset and the ID are stored; the ID is authoritative for arithmetic.

The Temporal.Now namespace provides the current moment in any of the useful forms:

const today = Temporal.Now.plainDateISO();
const nowInNewYork = Temporal.Now.zonedDateTimeISO('America/New_York');
const instant = Temporal.Now.instant();

All Temporal objects are immutable, and operations return new objects. You can pass a Temporal.PlainDate to any function without worrying it will be modified, which eliminates an entire class of bugs that Moment.js made infamous.

Bloomberg’s Stake in Correctness

Bloomberg’s involvement in Temporal is not abstract standardization work. Bloomberg Terminal runs significant amounts of JavaScript and needs to represent financial timestamps correctly: bond settlement dates, option expiry, market close times across exchanges in New York, London, and Tokyo.

A market close time like “NYSE closes at 16:00 Eastern” cannot be stored as a UTC offset, because the UTC offset for Eastern time changes twice a year. Storing 16:00-05:00 for a future date is wrong if that date falls after the DST transition. The only correct representation is 16:00[America/New_York], which is exactly what ZonedDateTime stores.

Historical timezone data compounds this: countries change their DST rules over time, and financial records must be correctly interpretable decades later. Bloomberg funds and maintains the @js-temporal/polyfill, the reference implementation of the full Temporal spec, in part because they have been running it internally while the proposal worked through TC39. This polyfill-driven development strategy meant the spec absorbed real-world feedback before it hardened.

Nine Years in TC39

The proposal entered Stage 1 in September 2017 and reached Stage 3 in March 2021. It has spent an unusually long time at Stage 3, long enough that TC39 introduced a new intermediate stage (Stage 2.7) while Temporal was already past it.

The extended timeline reflects genuine complexity. The champions group, including Philipp Dunkel, Maggie Pint, Matt Johnson-Pint, Richard Gibson, and Justin Grant, iterated substantially on the API surface between 2017 and 2021 before the spec stabilized enough for Stage 3. The polyfill approach allowed real-world testing that surfaced edge cases in calendar arithmetic and timezone handling that would have been much harder to catch in abstract spec review.

V8 shipped Temporal behind a flag in Chrome 108, and active implementation work continued in SpiderMonkey and JavaScriptCore through 2024 and 2025. As of early 2026, Stage 4 is imminent. The requirement is two independent browser implementations shipping without flags, and both Chrome and Firefox have been advancing toward that threshold.

Using Temporal Before Native Support Lands

The official polyfill is available on npm:

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

const today = Temporal.Now.plainDateISO();
const nextMonth = today.add({ months: 1 });

The polyfill weighs around 45KB minified and gzipped. For applications already shipping Luxon or Moment.js, the size trade-off is straightforward. If you want to use native Temporal where available and fall back selectively:

const { Temporal } = globalThis.Temporal
  ? globalThis
  : await import('@js-temporal/polyfill');

The polyfill is slower than native date operations because timezone resolution calls through Intl.DateTimeFormat. For typical UI work this is imperceptible; for data processing in tight loops, it is worth benchmarking before committing to the polyfill in a hot path.

Temporal does not remove Date. The existing API remains, and conversion is straightforward:

// From legacy Date to Temporal
const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
const zoned = instant.toZonedDateTimeISO('America/New_York');

The more interesting migration is conceptual. With Date, the habit is to reach for a library and trust that it handles the edge cases. With Temporal, the type system requires you to decide upfront whether you are working with an absolute moment, a calendar date, or a duration, and the arithmetic follows from that decision. The bugs that Date papers over with silent coercion have nowhere to hide in the Temporal model.

Nine years is a long time for a TC39 proposal. It is also about right for a change this fundamental, given the number of edge cases that needed to be discovered in real code, specified correctly, and verified across multiple engine implementations before the spec could be called finished.

Was this interesting?