cat posts/2025-01-25-javascript-temporal-dates-done-right.md

JavaScript Temporal: dates done right, at last

· 7 min read · javascript dates programming

const due = new Date('2025-01-31');
due.toLocaleDateString('en-US'); // "1/30/2025", if you're in New York

That’s a real bug from our product, more or less verbatim, from a few years ago. An invoice due date stored as a plain 2025-01-31 string, turned into a Date in the browser so it could be formatted, and shown to every customer west of Greenwich as the day before. JavaScript parses a date-only ISO string as midnight UTC, and midnight UTC on the 31st is 7 p.m. on the 30th in New York. Add a time to the string ('2025-01-31T00:00') and it parses as local time instead. Nobody on the team knew that rule. I didn’t either, until a customer in California asked why we’d marked their invoice overdue a day early.

So I was happy to see Mozilla’s JavaScript Temporal is coming post yesterday, along with what they say is more than 270 pages of new Temporal docs on MDN. It’s careful about the status: implementations are “starting to be shipped in experimental releases of browsers”, Firefox Nightly has the most complete one behind the javascript.options.experimental.temporal preference, Safari and Chrome have tracking bugs open, and stable cross-browser support is “a long way away”. You can try it today with the @js-temporal/polyfill package, which is what I’ve been doing this morning, and which is where every example in this post was run.

Why Date is the way it is

Date was copied from Java’s java.util.Date in 1995, in the ten days or so that JavaScript got for its first version. Java deprecated most of that class two years later, and spent the next seventeen years living with the mess until java.time landed in Java 8, built on Stephen Colebourne’s Joda-Time. JavaScript never got its do-over. Its Date is a single number (milliseconds since 1970 UTC) wearing a costume that pretends to be a local date and time, depending on which methods you call.

The famous complaints are mostly symptoms of that. Months are zero-based, so new Date(2025, 0, 31) is January 31st. It’s mutable, so the date you passed to a function can come back changed. It overflows silently:

const d = new Date(2025, 0, 31);
d.setMonth(1);         // "February 31st"
d.toDateString();      // "Mon Mar 03 2025"

And it only knows two time zones, UTC and whatever the machine it’s running on is set to. If you want to show a meeting time in Tokyo to someone in Chicago, Date can’t represent that. You end up with Intl.DateTimeFormat for display, a library for arithmetic, and a lot of hope in between. That’s why every project I’ve worked on for ten years has had Moment.js, then date-fns or Luxon, in its bundle, and why Moment’s own maintainers told everyone in 2020 to stop starting new projects with it.

The idea: different kinds of time are different types

Temporal’s answer is to stop pretending one type can do everything. It splits “a time” into several types depending on what you actually know, which is the same move Java made with java.time, and the same one Jon Skeet made in .NET years ago with Noda Time. I used Noda Time on a scheduling feature in my .NET days, and reading the Temporal docs felt like meeting an old friend who’d moved to a new city.

exact time wall-clock time (no zone) Instant a point on the timeline ZonedDateTime instant + zone + calendar PlainDateTime 2025-03-09T02:30 PlainTime PlainDate PlainYearMonth PlainMonthDay + zone drop it drop zone + zone (may be ambiguous) Duration the gap between any two
The Temporal types. Left: things that are a single moment. Right: things you'd read off a wall clock or a calendar, which aren't a moment until you say where.

Temporal.Instant is an exact point in time, nanoseconds since the epoch, with no time zone and no calendar. It’s what a log timestamp or a database timestamptz really is. Temporal.Now.instant() gives you the current one.

Temporal.ZonedDateTime is an instant plus a time zone (a real IANA zone like America/New_York, not an offset) plus a calendar. This is the type for “the meeting is at 9 a.m. in New York”, because it knows that New York’s offset changes twice a year and does arithmetic accordingly.

Temporal.PlainDateTime, PlainDate and PlainTime are wall-clock values with no zone at all. A birthday is a PlainDate. “The store opens at 09:00” is a PlainTime. My invoice due date was always a PlainDate, and the bug came from forcing it through a type that insisted on a moment. PlainYearMonth and PlainMonthDay are narrower still: a credit card expiry, an anniversary.

Temporal.Duration is the difference between two of these, P10M27D and friends, in ISO 8601 notation.

Everything is immutable. Every method returns a new object. That alone would have prevented at least two bugs I can remember.

The invoice, fixed

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

const due = Temporal.PlainDate.from('2025-01-31');
due.toLocaleString('en-US'); // "1/31/2025", everywhere

There’s no time zone in a PlainDate, so there’s nothing to shift. If you need to know whether it’s overdue for a particular customer, you ask the question explicitly, in their zone:

const today = Temporal.Now.plainDateISO('America/Los_Angeles');
Temporal.PlainDate.compare(today, due) > 0; // overdue?

Arithmetic that tells you what it did

Month arithmetic is the classic trap. Temporal clamps by default and lets you refuse instead:

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

February 28th, not March 3rd. I’m not sure clamping is right for every business rule (our billing code has opinions about the 31st that took a meeting to agree on), but at least it’s a documented choice with a switch, and 'reject' lets you find the places where you haven’t decided.

Differences come back as durations, and you choose the units:

const a = Temporal.PlainDate.from('2024-02-29');
const b = Temporal.PlainDate.from('2025-01-25');
a.until(b);                          // P331D
a.until(b, { largestUnit: 'months' }); // P10M27D

(I checked that second one by hand: February 29th plus ten months is December 29th, plus 27 days is January 25th.)

Daylight saving, in the API

I did the time zone half of this in October, with our scheduled-reports bug, gaps, overlaps and all, so I’ll stick to what Temporal does with them. US clocks spring forward on March 9th this year, and 2:30 a.m. that morning doesn’t exist in New York. Ask for it anyway:

Temporal.ZonedDateTime.from('2025-03-09T02:30[America/New_York]').toString();
// "2025-03-09T03:30:00-04:00[America/New_York]"

The default, 'compatible', moves you forward by the length of the gap, so 2:30 becomes 3:30. That’s close to the rule we picked by hand for the reports, but not the same: ours runs at the first valid time, 3:00. Worth knowing before anyone swaps one for the other. Pass { disambiguation: 'reject' } when converting a PlainDateTime and it throws instead, which is what I’d want anywhere a person typed the time in.

The bracketed zone in that string is worth a second look. Temporal’s string format is ISO 8601 extended with the zone name in brackets, which is the format Java’s ZonedDateTime has printed for years. The IETF standardized it last year as RFC 9557, and I believe that’s part of why Temporal spent so long at stage 3: the committee waited for the string format to be settled before calling it done.

The fall-back day shows the two kinds of “a day later” as two different calls:

const sat = Temporal.ZonedDateTime.from('2025-11-01T09:00[America/New_York]');
sat.add({ days: 1 });   // 2025-11-02T09:00:00-05:00, same wall-clock time
sat.add({ hours: 24 }); // 2025-11-02T08:00:00-05:00, exactly 24 hours later

Our report scheduler wanted days and had been doing hours. With Date you get the second whether you meant it or not.

Calendars

This is the part I expected to skip and didn’t. Every Temporal date has a calendar, ISO 8601 by default, and you can convert:

Temporal.PlainDate.from('2025-01-25').withCalendar('hebrew').toString();
// "2025-01-25[u-ca=hebrew]"

The underlying day is the same. What changes is what .month, .year and add({ months: 1 }) mean, which matters a lot if your users schedule things by the Hebrew, Islamic or Japanese calendar, and also if they don’t but your code assumes every year has twelve months. (The Hebrew calendar has thirteen in a leap year, which is why Temporal also has monthCode, a string like M05L, alongside the plain month number.) Growing up in India, where festival dates wander around the Gregorian year because they follow other calendars, this never struck me as an edge case the way it might to some people.

What I’m doing about it

Nothing in production yet. A polyfill for a stage 3 API, in a product that customers pay for, is a decision I’d want more than one Saturday morning to make, and the polyfill isn’t small. What I have done is go through our frontend for every new Date( that takes a date-only string, and there are eleven of them. Four are bugs waiting for the right customer. Those are getting fixed this week with the old workaround (parse the parts yourself and build a local date), and a comment pointing at Temporal for when we can use it.

The other thing I’ve done is send the type diagram above to our backend folks, who don’t need convincing anymore. The scheduler fix in the fall taught them the split: the schedule table stores a local time and an IANA zone now, our C# services use DateOnly in a few places, and the Rails side has always had Date vs Time. But the API still sends every value as a full timestamp, so the frontend never gets told which kind of value it’s looking at, and that’s how a due date ended up being treated as a moment. A due date should go over the wire as 2025-01-31. We’ve agreed that new endpoints do that, and the old ones get fixed as we touch them.

It’s been in the works since 2017. If it ships everywhere in the next couple of years, I’ll be happy to delete date-fns from our bundle, though knowing how long the last 10% of browser support takes, I’m not holding my breath.