Getting Started with the Temporal API
This guide shows how to adopt the Temporal API to replace the legacy JavaScript Date object with DST-safe, immutable, timezone-aware date logic. Part of Modern Date Logic with the Temporal API.
What Breaks with Legacy Date
The legacy Date object stores a single UTC millisecond count and then applies the host machine's local timezone to every property you read. That implicit coupling produces three recurring failures. A server in UTC and a browser in America/Los_Angeles disagree on which calendar day a timestamp belongs to, so a "due today" badge renders on different days. DST transitions silently break naive arithmetic — adding 24 hours across spring-forward lands an hour off the expected wall-clock time. And because setHours(), setDate(), and friends mutate in place, a shared Date reference passed through async code causes hydration mismatches and stale React state.
Temporal fixes the architecture rather than papering over it: every type is immutable, timezone context is explicit, and you choose a type that encodes what kind of time you mean before you write any arithmetic.
Picking the Right Type
Picking the type is a decision you make once per value, at the moment it enters your program, and getting it right there saves you from a cascade of conversions later. The deciding question is what the value fundamentally is. A moment that is the same everywhere on Earth — when a request arrived, when a row was written — is an Instant. A time that a person reads on a clock in a specific place — a 9am meeting in New York — is a ZonedDateTime, because the zone is part of its meaning. A date or time with no place attached — a birthday, a store's opening hour "9am" as a policy independent of any one location — is a PlainDate, PlainTime, or PlainDateTime.
The reason this matters so much is that each type only exposes operations that make sense for what it is, so the type system actively steers you away from nonsense. A PlainDate has no toInstant() because a civil date does not correspond to a single instant until you supply a zone; a PlainTime cannot be compared across days because it has no day. Those "missing" methods are guardrails: they force you to add the information an operation genuinely needs — a zone, a date — rather than letting you compute a plausible wrong answer from insufficient data, which is precisely what legacy Date allowed on every call.
The single most important decision in Temporal is choosing the correct type up front. Choosing wrong is a latent bug, not a style preference. The decision reduces to two questions: do you need a timezone, and do you need a clock time?
Here is the decision tree the rest of this guide builds on.
Type and Method Reference
| Type / method | Role | Timezone caveat |
|---|---|---|
Temporal.Instant |
Absolute point on the timeline (nanoseconds since epoch) | No zone, no calendar — .toString() is always UTC Z |
Temporal.ZonedDateTime |
Instant + IANA zone + calendar | DST-aware; the only type where .add({hours}) and .add({days}) differ |
Temporal.PlainDate |
Civil date, no time or zone | Never shifts across DST; format with timeZone: 'UTC' |
Temporal.PlainTime |
Wall-clock time, no date or zone | Recurring schedule times, business hours |
Temporal.PlainDateTime |
Date + time, no zone | Form input before the zone is known |
Temporal.Now.instant() |
Current absolute moment | Independent of host zone |
Temporal.Now.zonedDateTimeISO(tz) |
Current moment in an explicit zone | Pass an IANA id; defaults to host zone if omitted |
instant.toZonedDateTimeISO(tz) |
Attach a zone to an absolute moment | Resolves to the correct offset at that instant |
zdt.toInstant() |
Drop the zone, keep the absolute moment | Lossless round-trip back to UTC |
Approach A: The Legacy Date Solution
Before Temporal, the only built-in option was Date. It works for trivial cases but leaks the host zone everywhere.
// Legacy: "add one day" by adding 24 hours of milliseconds.
const start = new Date('2024-03-09T12:00:00-05:00'); // EST, day before US spring-forward
const next = new Date(start.getTime() + 24 * 60 * 60 * 1000);
// On a US/Eastern host this prints 13:00, not 12:00 — DST swallowed an hour.
console.log(next.toString());
The limitation is structural: Date has no concept of "one calendar day." It only knows milliseconds, so it cannot keep wall-clock time stable across a DST boundary. There is no API surface to express intent.
Approach B: The Temporal Solution
Temporal lets you say exactly what you mean. Adding a calendar day preserves wall-clock time; adding hours preserves absolute elapsed time.
import { Temporal } from '@js-temporal/polyfill';
const zdt = Temporal.ZonedDateTime.from(
'2024-03-09T12:00:00[America/New_York]'
);
// .add({ days: 1 }) keeps the wall-clock 12:00 even though the offset changes.
const sameClock = zdt.add({ days: 1 });
console.log(sameClock.toString()); // 2024-03-10T12:00:00-04:00[America/New_York]
// .add({ hours: 24 }) adds real elapsed time, so the clock reads 13:00.
const sameElapsed = zdt.add({ hours: 24 });
console.log(sameElapsed.toString()); // 2024-03-10T13:00:00-04:00[America/New_York]
When you construct a ZonedDateTime from a local string that is ambiguous (fall-back) or nonexistent (spring-forward), pass an explicit disambiguation:
import { Temporal } from '@js-temporal/polyfill';
// 02:30 on this date does not exist — clocks jump 02:00 -> 03:00.
const gap = Temporal.ZonedDateTime.from(
'2024-03-10T02:30:00[America/New_York]',
{ disambiguation: 'reject' } // throw instead of silently shifting forward
);
The disambiguation options are: 'compatible' (default, matches legacy Date and shifts forward into the gap / picks the earlier instant on overlap), 'earlier' and 'later' (explicit pick of the two candidate instants), and 'reject' (throw RangeError on any ambiguous or nonexistent time). For overflow when constructing dates, the parallel option is overflow: 'constrain' (clamp, the default) versus overflow: 'reject' (throw on Feb 30 and similar).
The deeper patterns for DST-correct arithmetic live in Working with ZonedDateTime Objects, and the pure-function arithmetic patterns in Date Arithmetic Without Mutations.
Environment Setup
Temporal reached TC39 Stage 4 (it is part of ES2026) and ships natively in recent browsers (Chrome 144+, Firefox 139+), but Safari and older runtimes still need a polyfill. Install @js-temporal/polyfill and pin it:
npm install @js-temporal/polyfill --save-exact
Import it directly in date-critical modules rather than relying on a global that may not be set yet:
import { Temporal } from '@js-temporal/polyfill';
// Optional: expose globally for modules that read globalThis.Temporal.
if (typeof globalThis.Temporal === 'undefined') {
(globalThis as any).Temporal = Temporal; // assign yourself — the polyfill does NOT
}
For a bundler-specific walkthrough, see Install and configure Temporal polyfill in Vite. For shrinking the payload, see Temporal polyfill bundle size and tree-shaking.
Production Implementation: A Safe Parse-and-Convert Utility
Most real bugs occur at the boundary where untrusted strings enter your system. This utility validates input, picks the right type, and converts to an absolute Instant for storage — the one representation that never drifts.
import { Temporal } from '@js-temporal/polyfill';
interface ParseResult {
instant: Temporal.Instant;
zoned: Temporal.ZonedDateTime;
}
/**
* Parse a wall-clock string in an explicit zone, rejecting ambiguous times,
* and return both the absolute instant (for storage) and the zoned value.
*/
export function parseWallClock(
isoLocal: string,
timeZone: string
): ParseResult {
// PlainDateTime carries no zone, so parsing cannot silently apply the host zone.
const plain = Temporal.PlainDateTime.from(isoLocal, { overflow: 'reject' });
// Attach the zone explicitly; 'reject' surfaces spring-forward gaps as errors.
const zoned = plain.toZonedDateTime(timeZone, { disambiguation: 'reject' });
return { instant: zoned.toInstant(), zoned };
}
// SSR / serverless note: store instant.toString() (UTC). Never store the host
// zone or a numeric offset. Re-attach the user's IANA zone on read.
On the server, always normalize to Instant (UTC) and store that string. Never persist a numeric offset like -05:00 — offsets change with DST and with political zone changes, so they cannot reconstruct the original zone. Store the IANA identifier alongside the instant when you need to reproduce the user's wall clock.
Edge Cases
The first edge case newcomers hit is that Temporal.PlainDate.from is strict where legacy Date parsing was permissively wrong. A malformed or ambiguous string throws instead of producing an Invalid Date (or worse, silently guessing a wrong interpretation), which feels stricter but means bad input fails at the boundary rather than propagating as a corrupt value. Embrace it: wrap parsing in a function that catches the throw and handles it explicitly, and you get a clean separation between valid and invalid input instead of the Invalid Date values that legacy code lets leak downstream.
The second is that some conversions require a zone and Temporal will not fabricate one. Turning a PlainDateTime into an Instant demands toZonedDateTime(zone) first, because a wall-clock reading is not a moment until you say where. Near a daylight-saving transition that conversion can be ambiguous or impossible, so it takes a disambiguation option — another place Temporal surfaces a decision legacy Date made silently and often wrongly. Treating these required arguments as helpful prompts rather than friction is the mindset shift that makes the API click.
Spring-forward gap
On the US spring-forward date, 02:30 local time does not exist. With disambiguation: 'compatible' Temporal shifts forward to 03:30; with 'reject' it throws. Decide per use case — a calendar invite should usually 'reject' and re-prompt the user.
Fall-back overlap
On fall-back, 01:30 occurs twice. 'earlier' picks the first (still DST), 'later' picks the second (standard time). Logging systems want 'earlier'; "the second time the bartender calls last orders" wants 'later'.
Civil dates have no DST
A PlainDate like a birthday must never be run through zone math. Converting it to a Date for formatting without timeZone: 'UTC' is the classic off-by-one bug — see the calendar-grid patterns in How to use Temporal.PlainDate for calendar apps.
Internationalization and Formatting
Temporal objects format through Intl for locale-aware output. Intl.DateTimeFormat construction is expensive, so cache the formatter and reuse it. Always pass an explicit timeZone so server rendering does not drift to the host zone.
import { Temporal } from '@js-temporal/polyfill';
// Cache the formatter — constructing it per render is a measurable hot path.
const berlinFormatter = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', // explicit — never inherit the host zone on a server
dateStyle: 'full',
timeStyle: 'short',
hourCycle: 'h23',
});
const zoned = Temporal.Now.zonedDateTimeISO('Europe/Berlin');
// epochMilliseconds is a bigint; Number() is safe for dates within ±285,000 years.
console.log(berlinFormatter.format(new Date(Number(zoned.epochMilliseconds))));
For non-Gregorian output, pass a calendar and see Calendar Systems and Era Handling.
Gotchas and Common Pitfalls
- Parsing a local string with
Temporal.Instant.from(). It requires aZor offset and throws otherwise. UsePlainDateTime.from(str).toZonedDateTime(tz)for wall-clock input. - Calling
Temporal.Duration.between(). It does not exist. Usestart.until(end)onInstant,PlainDate, orZonedDateTime. - Expecting the polyfill to set the global
Temporal. It does not — assignglobalThis.Temporal = Temporalyourself if you need it. - Using
.add({ hours: 24 })to mean "next day." Across DST that lands an hour off. Use.add({ days: 1 })on aZonedDateTimeto keep wall-clock time. - Storing numeric offsets instead of IANA zones. Offsets drift with DST and law changes. Store
Instant+ IANA id. - Formatting a
PlainDatewithouttimeZone: 'UTC'. The formatter treats it as UTC midnight and the host zone can shift the displayed day by one.
Testing Checklist
Run your date suite under multiple TZ values so host-zone leakage fails loudly in CI.
| Scenario | Input | Expected |
|---|---|---|
| Wall-clock day add over spring-forward | 2024-03-09T12:00[America/New_York] .add({days:1}) |
2024-03-10T12:00:00-04:00 |
| Absolute hour add over spring-forward | same .add({hours:24}) |
2024-03-10T13:00:00-04:00 |
| Nonexistent local time, reject | 2024-03-10T02:30[America/New_York] |
RangeError |
| Feb 30 with reject overflow | PlainDate.from('2024-02-30',{overflow:'reject'}) |
RangeError |
| Instant round-trip | zdt.toInstant().toZonedDateTimeISO(tz) |
equals original |
# Run the suite under several zones to catch host-zone assumptions.
for tz in UTC America/New_York Australia/Lord_Howe Asia/Kolkata; do
TZ=$tz npx jest date
done
Frequently Asked Questions
Is the Temporal API ready for production?
Yes. Use @js-temporal/polyfill for broad support; it is stable and widely adopted. Native support ships in Chrome 144+ and Firefox 139+, with Safari progressing. Pin the polyfill version in package.json and conditionally skip it when a native Temporal is present.
How do I choose between PlainDate, Instant, and ZonedDateTime?
Ask whether you need a timezone. If the value is an absolute moment for storage or logs, use Instant. If you need wall-clock fields in a real zone for scheduling or display, use ZonedDateTime. If there is no timezone at all, use PlainDate, PlainTime, or PlainDateTime depending on which fields you need.
Can I migrate from legacy Date incrementally?
Yes. Bridge with Temporal.Instant.fromEpochMilliseconds(date.getTime()) to move into Temporal and new Date(Number(instant.epochMilliseconds)) to move back. Convert at the boundary and operate in Temporal internally.
Why does adding 24 hours give the wrong time across DST?
Because 24 hours is absolute elapsed time, and a spring-forward day is only 23 hours of wall clock. Use .add({ days: 1 }) on a ZonedDateTime to keep the clock time fixed; reserve .add({ hours }) for when you genuinely mean elapsed time.
What is the first thing to get right when adopting Temporal?
Pick the correct type for each value at the point it enters your program. Ask what the value fundamentally is: a place-independent moment (Instant), a time read on a local clock (ZonedDateTime), or a civil date/time with no zone (PlainDate, PlainTime, PlainDateTime). Because each type only exposes operations that make sense for it, choosing correctly up front steers you away from whole categories of bug and avoids a cascade of conversions later.
Why does Temporal.PlainDate.from throw instead of returning Invalid Date?
Because strict parsing fails bad input at the boundary rather than letting a corrupt value propagate. Legacy Date returned an Invalid Date object (or silently guessed a wrong interpretation) that then leaked downstream and surfaced far from its cause. Temporal throws on malformed or ambiguous strings, so you wrap parsing in a function that handles the error explicitly and keep a clean separation between valid and invalid input.
Why do I need to supply a zone to convert a PlainDateTime to an Instant?
Because a wall-clock reading is not a single moment until you say where it is read. The same '9am on March 15' is a different instant in Tokyo than in New York, so toInstant is deliberately unavailable on PlainDateTime; you call toZonedDateTime(zone) first. Near a daylight-saving transition that conversion can be ambiguous or impossible, so it also takes a disambiguation option — a decision legacy Date made silently and often incorrectly.