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.

Date conflates instant and wall-clockOne type, many jobsDate conflates instant and wall-clockDate for civil + absolutemutable, host-zonedInstant/Plain/Zoned splittyped intentLegacy Date forces one mutable type to mean instants, civil dates, and wall times at once.

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.

Temporal type decision treeStart at the question of whether the value needs a timezone. If it needs an absolute instant, use Temporal.Instant. If it needs a wall-clock time in a real zone, use ZonedDateTime. If no timezone is needed, choose PlainDate, PlainTime, or PlainDateTime depending on whether you need date, time, or both.Need a timezoneor absolute moment?yesno (civil time)Absolute point only,no human fields?yesnoInstantstorage, logsZonedDateTimescheduling, displayNeed date, time,or both?PlainDatebirthdaysPlainTime /PlainDateTimeAdd wall-clock days with .add({ days }); add absolute time with .add({ hours }).Only ZonedDateTime knows DST — Plain types never shift offsets.

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

Temporal starter referenceThe types you reach for firstTemporal starter referenceTemporal.Now.instant()current absolute timeTemporal.PlainDate.fromcivil date from stringTemporal.ZonedDateTime.frominstant + zone from stringvalue.toString()round-trippable ISO

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.

Legacy DateOne mutable type for everythingLegacyModernnew Date(...)mutable, host zone0-indexed monthsambiguous intentInstant/PlainDate/Zonedimmutable, explicit1-indexed fieldstyped 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.

Parse into the right typeClassify the value, parse into a Temporal typeParse into the right typeinputclassifyinstant/civilTemporalvalue

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.

Add the polyfillInstall @js-temporal/polyfill until native support is universalAdd the polyfillnpm ipolyfillimport{ Temporal }feature-detect native

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.

Safe parse utilityValidate, parse strictly, return a typed value or throwSafe parse utilityraw inputclassifyfrom() (try)typed / error

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.

Getting-started edge casesWhere beginners slipGetting-started edge casesWrong typeInstant for civil→ no zone1-indexedmonth 1=Jannot 0Native vs polyfillfeature detectload once

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.

Format with IntlTemporal values format through Intl.DateTimeFormatFormat with IntlTemporal valuetoLocaleString/ Intllocalized

Gotchas and Common Pitfalls

Starter pitfallsOld habits, new APIWrongRightreach for Instant alwayscivil needs PlainDatematch type to intentclear codeexpect 0-indexed monthsTemporal is 1-indexedmonth 1 = Januarycalendar-aligned

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

Getting-started test matrixTypes and parsing assert correctlyAssertions that prove the edge casePlainDate.from('2024-03-15')civil datemonth3 (1-indexed)bad stringthrowstoString round-tripequal

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.