Skip to content

Dates on Temporal

Every date the engine works with is an instant: a number of milliseconds since 1 January 1970, with no time zone attached. Every question it answers about a date is a calendar question about that instant: which day it falls on, what the same time a month later is, whether it is a Saturday, how it is written out. Answering needs a time zone and a set of calendar rules, and the engine takes both from Temporal wherever the runtime has one. Where it does not, it falls back to the JavaScript Date object, which reads the zone of the process it runs in and nothing else.

You do not have to do anything to get this. This page is about what the choice is, how to pin it when a result must not depend on where it was computed, and how to get Temporal on a runtime without one.

If all you want is a time zone rather than a calendar implementation, see choosing a zone: it needs nothing installed and works on whichever backend you are on.

Temporal is the JavaScript standard library’s replacement for Date. Where Date is one number wearing the process’s time zone, Temporal has a type for each thing a date can be: an instant, a calendar date with no time, a wall-clock time, and a date-time pinned to a named zone such as Asia/Tokyo. The last of those is what Date cannot express, and it is why the engine can compute a document in Tokyo from a server in London once it has Temporal to hand.

It is new enough that not every runtime has it. Node 26 ships it switched on; Node 22 and 24, which the engine also supports, do not (Node 24 keeps it behind --harmony-temporal). Current Chrome, Edge, Firefox, Deno and Bun have it; Safari does not, in any stable release. Where the runtime has none, a polyfill supplies it.

What the engine ships, and what it does not

Section titled “What the engine ships, and what it does not”

The engine reads globalThis.Temporal and uses it when it is there. It bundles the adapter, the few kilobytes that translate the engine’s calendar contract onto whichever implementation it finds: 1,645 bytes gzipped, measured as the difference in the root entry’s compressed size with and without it.

It bundles no polyfill, and that is the deliberate part. The smallest, temporal-polyfill 1.0.4, adds 20.4 KB gzipped (58.6 KB minified), measured by bundling its entry with esbuild and compressing with gzip at level 9. That is twelve times the adapter, paid by every host including one whose readers never type a date, and on a runtime that already has Temporal it would only duplicate what is there. A smoke test walks every chunk the root entry loads and fails if any of them names a polyfill package, or if the adapter has gone missing and the engine can no longer default to Temporal at all.

Where you want Temporal on a runtime without one, you install the polyfill and hand it over. That is what solve-engine/temporal is for, and it is the only way a polyfill enters your bundle: because you put it there.

By default the engine chooses for itself: Temporal where the runtime has it, Date where it does not. The calendar option pins that choice.

calendarwhat the engine computes on
omitted, or "auto"Temporal where the runtime has it, Date otherwise
"temporal"Temporal, refusing to build an engine on a runtime without one
"date"Date, whatever the runtime has
a backendthe one you built, from a polyfill or bound to a zone
import { createEngine } from "solve-engine";
const engine = createEngine({ calendar: "date" });

Pin "date" when a result must not depend on where it was computed, and "temporal" when you would rather an engine refuse to start than quietly compute on Date. Asking for "temporal" on a runtime with none throws a coded CALENDAR_TEMPORAL_UNAVAILABLE error naming both ways out.

The two backends are held to the same answers, which is what makes choosing between them safe rather than a coin toss: npm run test:temporal runs the date suites under both, in three time zones, and a differential suite compares them case by case. A reader on Firefox and a reader on Safari see the same number.

A time zone and a calendar implementation are two different asks, and a host often wants only the first. dateCalendarInZone answers it on the Date backend: it takes an IANA zone name and hands back a backend that reads that zone as “local”, with no polyfill and nothing added to the bundle. Reach for it when you want a named zone and do not care which implementation computes in it.

import { createEngine, dateCalendarInZone } from "solve-engine";
const engine = createEngine({ calendar: dateCalendarInZone("Asia/Tokyo") });

Everything the next section says about what a zone changes applies here too: a date literal is midnight in Tokyo, 9:00am is nine o’clock there, today is Tokyo’s day, and a day step across a daylight-saving change holds the wall clock rather than adding twenty-four hours. Reading the zone data is the same Intl.DateTimeFormat the Date backend already uses for time in Paris, so the answers come from the runtime’s own IANA database.

A zone this runtime cannot format with is refused where you name it, with a coded DATE_ZONE_UNKNOWN error, rather than answering in some other zone once per line:

dateCalendarInZone("Europe/Atlantis");
// throws: dateCalendarInZone("Europe/Atlantis") is not a time zone this runtime knows.

There is deliberately no date.zone configuration field beside it. The zone belongs to the calendar backend, which already owns what “local” means; a second place to say it is how the two come to disagree.

The one thing this does not give you is Temporal’s own answer for a wall clock a daylight-saving change skipped or repeated. 01:30 on a spring-forward morning never happened, and on a fall-back morning it happened twice, so the instant it names is a choice; Temporal makes that choice explicitly with disambiguation: 'compatible' and this backend makes it with an offset lookup, and the two can differ by an hour for exactly those readings. Every other answer is the same, which is what the rest of this page is about.

On Node 26 or a current browser, globalThis.Temporal is the implementation. Pass it directly.

import { createEngine } from "solve-engine";
import { createTemporalCalendar } from "solve-engine/temporal";
const calendar = createTemporalCalendar(globalThis.Temporal, { timeZone: "Asia/Tokyo" });
const engine = createEngine({ calendar });

TypeScript 5.9 does not yet declare Temporal in its standard library, so the property is not typed. createTemporalCalendar takes a TemporalLike, a structural description of the small part of the namespace the backend uses, and a typed read of the global fits it:

import { createTemporalCalendar, type TemporalLike } from "solve-engine/temporal";
const native = (globalThis as { Temporal?: TemporalLike }).Temporal;
if (native === undefined) throw new Error("this runtime has no Temporal; install a polyfill");
const calendar = createTemporalCalendar(native);

A value that is not a usable Temporal (a missing Now.instant, say) is refused at construction with a coded TEMPORAL_IMPLEMENTATION_INVALID error naming the member, rather than failing inside the first date computed.

Where the runtime has no Temporal, install one and pass its export. The temporal-polyfill package is MIT-licensed and the one the engine’s own test suite uses:

import { Temporal } from "temporal-polyfill";
import { createTemporalCalendar } from "solve-engine/temporal";
const calendar = createTemporalCalendar(Temporal, { timeZone: "Europe/Paris" });

Its default entry hands back the runtime’s native Temporal when there is one and its own implementation otherwise, so the same line serves both kinds of host; temporal-polyfill/implementation always gives the polyfill’s own. Installing it globally (temporal-polyfill/global) works too, after which globalThis.Temporal is the value to pass.

Two things sit outside the engine and are told about the backend separately. formatValue is a free function with no engine in hand, so it reads the backend from its settings: pass the same one, and a date displays in the zone it was computed in.

import { formatValue } from "solve-engine/format";
import { DEFAULT_FORMATTING_SETTINGS } from "solve-engine/format";
formatValue(engine.evaluateExpression("today"), { ...DEFAULT_FORMATTING_SETTINGS, calendar });

A worker cannot receive it from the main thread: a backend is an object of functions, and functions do not cross a postMessage boundary. A host running the engine behind solve-engine/worker bakes the backend into its worker entry, the way it bakes in a custom package, and the runtime applies it to the formatting the main side sends:

import { startWorkerRuntime } from "solve-engine/worker";
import { createTemporalCalendar } from "solve-engine/temporal";
startWorkerRuntime(transport, { calendar: createTemporalCalendar(globalThis.Temporal, { timeZone: "Asia/Tokyo" }) });

The Date backend reads the process’s zone. The Temporal backend reads the zone it was built with, timeZone, which defaults to the runtime’s own so that leaving it out changes nothing. Name a zone, and every date the engine computes is computed there: today is that zone’s day, a date literal is midnight there, 9:00am is nine o’clock there, and days in February 2024 is read there.

At 22:00 UTC on 26 August 2026 it is still Wednesday in New York and already Thursday in Tokyo, and two engines built in those zones say so:

const tokyo = createEngine({ calendar: createTemporalCalendar(Temporal, { timeZone: "Asia/Tokyo" }) });
const newYork = createEngine({ calendar: createTemporalCalendar(Temporal, { timeZone: "America/New_York" }) });
tokyo.evaluateExpression("today as weekday").value; // "Thursday"
newYork.evaluateExpression("today as weekday").value; // "Wednesday"

A zone the implementation does not know is refused at construction with a coded TEMPORAL_TIME_ZONE_UNKNOWN error, so a misspelt zone is a configuration fault seen once rather than a RangeError inside every date.

The backend also takes a now option, a function answering the current instant in epoch milliseconds, for a test that needs a fixed date. It exists because fake-timer libraries replace Date.now, which the Date backend reads, but not Temporal.Now.

That is the whole of the observable difference. Every other answer is the same whichever backend an engine computes with, and this is a constraint the backend is built to rather than a hope: Temporal and Date disagree by design about an instant past the range Date represents (a RangeError against NaN), a fractional millisecond (a throw against truncation), a day past the end of a month (a clamp against a roll into the next month) and a year from 0 to 99 (read literally against read as the 1900s), and the backend reproduces Date’s reading of each. A month step still clamps to the end of the month, a working-day count still skips the same weekends, a named-zone conversion still answers the same wall-clock time:

31/01/2024 + 1 month // Thursday, February 29, 2024
2nd Tuesday of March 2026 // Tuesday, March 10, 2026
working days between 01/01/2024 and 31/01/2024 // 23
3pm Tokyo in Delhi // 11:30 AM

These are proven on the Date backend by the documentation’s own test, and a differential suite (packages/engine/__tests__/temporal/) runs every documented example and a corpus of date and time forms through both backends at fifteen pinned instants, the daylight-saving days of five zones among them, and asserts the two answers are identical: the value’s type, its display, and a date’s instant to the millisecond. npm run test:temporal goes further and runs the whole of the engine’s date and time suites with every engine on the Temporal backend, in London, New York and Auckland, polyfilled on Node 22 and native on Node 26.

  • Two implementations, one known defect. temporal-polyfill 1.0.4 reads London as GMT between 16 March and 13 April 1947, a year with three transitions, where Intl, Date and the native Temporal all say BST. Across thirteen zones and the years 1840 to 2120 it is the only place the polyfill and the runtime disagree; the differential suite excludes that window under the polyfill and asserts the defect is still there, so the exclusion goes when the polyfill is fixed.
  • Strings outside the ISO 8601 format. "…" to date admits only the format, so both backends read the same strings. Given a string outside it directly, the Date backend may guess through the runtime’s legacy parser ("2019/04/01" reads as local midnight in V8) where the Temporal backend answers NaN; nothing in the engine passes such a string.
  • Spans that cross a daylight-saving change. days between two dates counts whole calendar days, so days between 28 March 2026 and 30 March 2026 is 2 days in every zone even where the clocks moved between them. Subtracting one date from another is still elapsed time in milliseconds, so 30 March 2026 - 28 March 2026 reads 47:00 in London and 48:00 where nothing changed that weekend. That is the engine’s own arithmetic and the same on both backends.
  • The offsets of local mean time. Before a zone adopted standard time (London in 1847, New York in 1883) its offset was not a whole number of minutes. Date truncates it to whole minutes, and the backend does the same, so as iso8601 on an 1840 date shows the same offset either way.
  • The engine’s inline offload worker computes with the Date backend; the public solve-engine/worker runtime takes the option as shown above.