· 7 min read ·

Twelve Months Is Not a Number of Days

Source: hackernews

When Temporal shipped in Firefox 139, it ended a nine-year TC39 proposal that produced over 200 pages of spec text for a problem that, on the surface, sounds manageable: fix JavaScript’s broken Date object. The Bloomberg retrospective on that journey covers the political and process dimension well. What it does not spend much time on is why calendar arithmetic specifically resists clean API design in a way that, say, string handling does not.

The answer lives in Temporal.Duration, and in an error message that trips up nearly every developer who first encounters it.

The Error You Will Hit First

You start using Temporal. You create two PlainDate values, compute a duration, and try to flatten it:

const diff = Temporal.Duration.from({ months: 12 });
diff.total('days'); // RangeError: a starting point is required for this operation

You have a duration of 12 months. You want to know how many days that is. How can that require a starting point?

The answer is that 12 months is 365 days in a non-leap year and 366 days in a leap year. It is 334 days if the relevant months happen to be February through November. It cannot be converted to days without knowing which 12 months you mean. Every library that silently returned a fixed answer for questions like this was producing wrong results somewhere downstream. Date, Moment.js, and date-fns all answered it by treating a month as roughly 30.44 days and hoping the calculations were not sensitive enough to notice.

Temporal.Duration refuses to do that. It stores components separately, { years: 0, months: 12, weeks: 0, days: 0, hours: 0, ... }, and never collapses calendar units to a numeric count. To total it, you provide a relativeTo date that anchors the arithmetic:

const d = Temporal.Duration.from({ months: 12 });

d.total({ unit: 'days', relativeTo: Temporal.PlainDate.from('2025-01-01') });
// 365 — 2025 is not a leap year

d.total({ unit: 'days', relativeTo: Temporal.PlainDate.from('2024-01-01') });
// 366 — 2024 is a leap year

This is the only honest answer. The design philosophy that produced it runs through the entire proposal.

How Every Language Ecosystem Learned This

What makes Temporal interesting is not that it is new. It converged on the same solution that every major language ecosystem independently arrived at when forced to take calendar correctness seriously.

Java shipped java.util.Date in 1996 and deprecated most of its methods in 1997, within a year of introducing java.util.Calendar as an improvement that was not actually much better. By the early 2000s, Stephen Colebourne had released Joda-Time, which introduced the type separation that the standard library lacked: LocalDate, LocalTime, LocalDateTime, DateTime for timezone-aware values, Instant, Duration, and Period. Java 8 formalized this in the java.time package through JSR-310, designed by the same author. The type taxonomy — civil time separate from absolute time, with timezone-aware types distinct from both — was not a Java-specific decision. The problem required it.

C# developers built Noda Time when Jon Skeet found .NET’s System.DateTime unworkable for correct calendar arithmetic. Noda Time maps almost directly onto Temporal: LocalDate, LocalDateTime, ZonedDateTime, Instant, Duration, Period. Jon Skeet wrote a substantial guide on the conceptual model explaining why the type separation is necessary. The language differs; the taxonomy is the same.

Python’s datetime module ships a datetime type that conflates timezone-aware and naive values in a way that produces quiet bugs. Python 3.9 added the zoneinfo module for IANA timezone support, and the datetime.date / datetime.datetime split improved things, though the aware/naive conflation remains a trap. Rust’s chrono crate, the standard for time in Rust, uses NaiveDate, NaiveDateTime, and timezone-aware DateTime<Tz> — again, the same three-way split between civil date, civil datetime, and timezone-attached datetime.

The convergence is not coincidence. It reflects the structure of the problem. Calendar time and absolute time have different algebras. Operations valid on one are not valid on the other. Mixing them in a single type means either requiring explicit disambiguation at every operation or silently picking wrong defaults. Every ecosystem that took correctness seriously arrived at the same conclusion.

JavaScript got there last because, unlike languages with smaller ecosystems, the stakes of breaking changes are enormous and the standardization process requires broad consensus. Moment.js, which the JavaScript community used for over a decade, never made the distinction. It has one type that tries to be all of these things simultaneously, controlled by whether you call moment() versus moment.utc() versus moment.tz(). That structural ambiguity is precisely what makes Moment’s behavior surprising. The two Moment.js authors who became TC39 Temporal champions did so because building and maintaining Moment taught them what could not be fixed from userland.

The Type Separation in Practice

Temporal’s type family maps directly onto the distinction every other ecosystem arrived at:

  • Temporal.Instant: UTC nanosecond count. Absolute time. No calendar, no timezone. Use for event logs, database records, and any context where “when exactly” matters regardless of location.
  • Temporal.PlainDate: Calendar date only, no time-of-day, no timezone. Birthdays, business dates, deadlines, anything where “what time zone” is not a meaningful question.
  • Temporal.PlainDateTime: Calendar date plus wall-clock time, no timezone. Useful for recurring schedules specified before a location is known, or for local logging where the system timezone is implicit.
  • Temporal.ZonedDateTime: Calendar date, wall-clock time, and IANA timezone. DST-correct arithmetic. Scheduled events that need to survive clock changes.
// The 24-hours vs. 1-day distinction that Date cannot express
const before = Temporal.ZonedDateTime.from('2025-03-09T01:30:00[America/New_York]');

before.add({ hours: 24 });
// 2025-03-10T02:30:00-04:00[America/New_York] — physically 24 hours later

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

For bot scheduling, this distinction is constant. “The reminder fires in 24 hours” means physical time; add hours. “The weekly digest sends every Monday at 9 AM” means wall-clock time; add days and preserve the hour. Date has no way to express which you mean. ZonedDateTime gives you both options explicitly.

Months are 1-indexed throughout. Temporal.PlainDate.from('2025-03-11').month returns 3. This alone eliminates a class of off-by-one errors that has appeared in JavaScript code for 30 years.

DST Disambiguation

DST transitions create two categories of illegal or ambiguous values: times that do not exist because clocks sprang forward over them, and times that occur twice because clocks fell back through them. Date handles both silently. Temporal requires you to declare intent:

// 2:30 AM on March 9, 2025 does not exist in New York
// Clocks spring forward from 2:00 to 3:00
Temporal.ZonedDateTime.from(
  { year: 2025, month: 3, day: 9, hour: 2, minute: 30,
    timeZone: 'America/New_York' },
  { disambiguation: 'earlier' }  // use 1:30 AM, before the gap
);

// 'later'      — use 3:30 AM, after the gap
// 'compatible' — match Date's behavior
// 'reject'     — throw RangeError

The 'compatible' option exists specifically for migration: you can switch incrementally, starting with 'compatible' to match existing behavior and tightening the semantics as you verify each operation.

Calendar Arithmetic Without a Reference Point

Returning to Duration: the relativeTo requirement is not just about totaling. It also appears in duration rounding and in comparing calendar-aware durations. The until() method applies it when computing how many months lie between two dates:

const start = Temporal.PlainDate.from('2025-01-31');
const end   = Temporal.PlainDate.from('2025-03-01');

start.until(end, { largestUnit: 'months' });
// P1M1D — 1 month (Jan 31 to Feb 28) plus 1 day (Feb 28 to Mar 1)
// Not P29D, which is what a millisecond-based calculation would produce

Month-end arithmetic surfaces the same problem. Adding one month to January 31 produces a date that does not exist:

const jan31 = Temporal.PlainDate.from('2025-01-31');

jan31.add({ months: 1 }, { overflow: 'constrain' }); // 2025-02-28
jan31.add({ months: 1 }, { overflow: 'reject' });    // RangeError

// Date's behavior: silently produce May 1 from March 31 + 1 month
// because April 31 overflows into the next month

Every place where Date picked a silent default, Temporal either surfaces the decision explicitly or provides a clearly documented default.

Current Status

Temporal ships natively in Firefox 139+ and has landed in V8, making it available in recent Chrome and Node.js builds. For older environments, the @js-temporal/polyfill package provides full coverage:

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

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

The polyfill weighs around 380KB minified because it bundles the IANA timezone database and calendar computation tables. That cost drops to zero once native implementations cover your target environments. Moment.js is in maintenance mode and its documentation recommends Temporal as the forward path. Luxon handles DST correctly through Intl.DateTimeFormat but remains a userland dependency with a single primary type. date-fns is a solid functional wrapper that inherits all of Date’s structural limitations.

What Nine Years Actually Required

The Temporal proposal began in 2017, reached Stage 3 in March 2021, and shipped in Firefox in 2025. The spec covers DST semantics in detail, approximately 20 calendar systems including Hebrew, Islamic, Persian, and Japanese via CLDR data, custom calendar and timezone extension hooks, nanosecond precision backed by BigInt, and Intl.DateTimeFormat interoperability throughout.

That is not committee overhead. It is the cost of specifying something precisely enough to guarantee identical behavior across all JavaScript engines. Java 8 went through the same process; so did Noda Time’s extensive documentation of edge cases. Getting calendar arithmetic right is expensive to specify because the edge cases are numerous and the stakes of silent misbehavior are high.

The relativeTo error is the clearest summary of the design. Twelve months is not a number of days, and it never was. Date libraries that returned a number anyway were providing a convenience that cost correctness. Temporal is the first part of the JavaScript standard library honest enough to ask which twelve months you mean.

Was this interesting?