How it works
A type is defined by which fields it stores. Instant holds one BigInt of epoch nanoseconds. PlainDate holds an ISO year, month and day plus a calendar id; PlainTime holds hour to nanosecond. ZonedDateTime holds epoch nanoseconds, a time zone id and a calendar id, and derives its date and time fields from them. That is why it is the only type that can answer "what is 24 hours later" and "what is the same wall time tomorrow" differently.
ZonedDateTime serializes to RFC 9557: 2026-03-07T09:00:00-05:00[America/New_York]. The offset records the moment; the bracketed id lets a reader re-apply the zone's rules later. Duration holds signed integer fields for each unit (all non-zero fields share one sign) and is not balanced on creation. Converting between calendar units and exact units needs a reference point:
import { Temporal } from '@js-temporal/polyfill';
const month = Temporal.Duration.from({ months: 1 });
console.log(month.total({ unit: 'day', relativeTo: '2026-02-01' }), month.total({ unit: 'day', relativeTo: '2026-03-01' }));
// → 28 31
try { month.total({ unit: 'day' }); } catch (e) { console.log(e.name); }
// → RangeError
console.log(Temporal.Duration.from({ hours: 25 }).round({ largestUnit: 'day' }).toString());
// → P1DT1H
Output above matches natively in Chrome 155 and with the polyfill on Node 24.19.0, like every Temporal output in this question.
Edge cases
Instant rejects calendar units: instant.add({ days: 1 }) throws a RangeError, because a day is not a fixed length. Use hours, or convert to ZonedDateTime.
PlainDate.from('2026-03-08T12:00:00Z') throws: a Z designator means an exact moment, which a plain value must not silently discard.
- Formatting goes through
toLocaleString(). new Intl.DateTimeFormat().format(zonedDateTime) throws a TypeError (the polyfill's message says to call toLocaleString()), and options that do not apply to the type are rejected (a timeStyle on a PlainDate). Both throw natively in Chrome 155 and in the polyfill; I did not read the matching ECMA-402 text.
toString() for Instant is always UTC (…Z); to show a zone, convert first.
Date.prototype.toTemporalInstant exists only where Temporal does; the polyfill exports it as a standalone function instead.
Real-world scenario
A monthly billing date anchored on the 31st. Chaining add({ months: 1 }) drifts, because each clamp loses the original day. Add the total offset to the anchor instead:
import { Temporal } from '@js-temporal/polyfill';
const anchor = Temporal.PlainDate.from('2026-01-31');
const chained = anchor.add({ months: 1 }).add({ months: 1 });
console.log(chained.toString(), anchor.add({ months: 2 }).toString());
// → 2026-03-28 2026-03-31
const bill = (n) => anchor.add({ months: n }).toString();
console.log([1, 2, 3].map(bill));
// → [ '2026-02-28', '2026-03-31', '2026-04-30' ]
With Date the same logic needs manual last-day-of-month checks and mutation of a copy.
Trade-offs
Temporal costs a larger API surface (eight types) and an explicit choice at every call site; Date lets you skip that choice and then breaks. Native support is new, so libraries that serve older engines still need a polyfill, and Date remains the type many platform APIs return or accept. For an Instant that only needs ordering and storage, a plain epoch number or ISO string is still enough.
How I checked support: typeof Temporal was undefined on Node 22.23.2, 24.19.0, and on a Homebrew build of Node 26.7.0, whose process.config.variables.v8_enable_temporal_support was 0 (Node's build docs say Temporal support requires a build with temporal_rs). Node 26's release notes say it is enabled by default, and MDN's compatibility data lists Node 26.0.0, so treat my local Node 26 as a build-configuration exception, not the norm.