Modern Date Logic with the Temporal API
Legacy JavaScript Date is a single mutable type that tries to be a UTC instant, a local wall-clock value, and a parser all at once β and it fails at all three the moment a timezone or a DST transition is involved. The Temporal API replaces it with a family of immutable, purpose-built types that separate absolute time from wall-clock time and make the IANA timezone explicit everywhere. This guide is for full-stack and frontend engineers who keep hitting the same date bugs β off-by-one days near midnight, meetings that shift by an hour twice a year, "Invalid Date" from a string the backend swore was valid β and want a model that makes those bugs impossible to write. Start with the type model and getting started with the Temporal API, then layer on immutable arithmetic, DST-aware ZonedDateTime objects, and locale-correct calendar systems. This is the deep dive complementing the JavaScript Date fundamentals and the Intl & legacy Date patterns guides.
The single mental model to internalize first: every Temporal type sits on one side of an absolute vs wall-clock split. An Instant is a point on the global timeline with no human-readable fields; a PlainDateTime is human-readable fields with no point on the timeline. A ZonedDateTime is the bridge β it carries both, plus the IANA zone and calendar needed to convert between them losslessly.
The diagram below maps the whole type family and shows which conversions cross the absolute/wall-clock boundary (and therefore need a timezone) versus which stay on one side.
Concept Overview
| Type / API | Role |
|---|---|
Temporal.Now |
Entry point for "now" β Now.instant(), Now.zonedDateTimeISO(tz), Now.plainDateISO() |
Temporal.Instant |
Absolute UTC moment, nanosecond precision β storage, logging, ordering events |
Temporal.ZonedDateTime |
Instant + IANA zone + calendar β scheduling, display, DST-safe math (the workhorse) |
Temporal.PlainDateTime |
Wall-clock date + time, no zone β form inputs awaiting a timezone |
Temporal.PlainDate |
Calendar date only β birthdays, due dates, billing anchors |
Temporal.PlainTime |
Wall-clock time only β opening hours, daily alarms |
Temporal.PlainYearMonth |
Year + month β credit-card expiry, monthly reporting periods |
Temporal.Duration |
An amount of time (P1Y2M10DT2H30M) β the result of .until() / .since() |
.add() / .subtract() |
Immutable arithmetic; returns a new instance; takes a Duration or duration-like object |
.until() / .since() |
Difference between two same-type values, returns a Duration |
.with() |
Immutable field replacement (.with({ day: 1 })) |
disambiguation option |
DST policy for ambiguous/nonexistent wall times: 'compatible' / 'earlier' / 'later' / 'reject' |
overflow option |
Out-of-range field policy: 'constrain' (clamp) or 'reject' (throw) |
The rest of this guide works through four concerns in order: the type model and getting started; immutable arithmetic; ZonedDateTime and DST; and calendars plus durations. Each maps to a deeper section you can jump to.
1. The Type Model & Getting Started
The first job is choosing the right type. Reaching for ZonedDateTime everywhere is the most common beginner mistake β a birthday has no timezone, and tagging it with one invites off-by-one bugs when the value is read in a different zone. Pick the least specific type that captures the meaning of your data: a calendar date is a PlainDate, a recurring 09:00 alarm is a PlainTime, and only a value that pins a real moment in a real place is a ZonedDateTime.
import { Temporal } from '@js-temporal/polyfill';
// Each "now" helper returns a different type β pick by intent
const instant = Temporal.Now.instant(); // absolute UTC, no fields
const today = Temporal.Now.plainDateISO(); // calendar date in the host zone
const zdt = Temporal.Now.zonedDateTimeISO('Asia/Tokyo'); // full moment in an explicit zone
// Build from strings β the format implies the type
const birthday = Temporal.PlainDate.from('1994-07-21'); // no time, no zone
const openingTime = Temporal.PlainTime.from('09:00'); // no date, no zone
const meeting = Temporal.ZonedDateTime.from(
'2026-06-15T09:00:00-07:00[America/Los_Angeles]' // bracketed IANA zone is required for ZDT
);
console.log(birthday.dayOfWeek); // 4 (ISO: Monday=1 β¦ Sunday=7) β a property, not getDay()+offset math
Note the field access: Temporal exposes year, month, day, hour, dayOfWeek, daysInMonth, and friends as plain readable properties β months are 1-based, unlike Date.prototype.getMonth(). There is no parser ambiguity either: from() accepts only ISO 8601 (with the bracketed [IANA/Zone] annotation for zoned values) and throws on anything else, so a malformed string fails loudly instead of producing a silent Invalid Date.
For setup β installing @js-temporal/polyfill, configuring bundlers, and the first PlainDate calendar-app patterns β see getting started with the Temporal API. If bundle weight matters, the polyfill is large; the Temporal polyfill bundle size and tree-shaking guide covers minimizing its footprint.
2. Immutable Arithmetic Without Mutation
Every Temporal value is frozen. legacyDate.setHours(legacyDate.getHours() + 24) mutates a shared object and silently drifts across DST; the Temporal equivalent returns a brand-new instance and leaves the original untouched. This is the foundation of date arithmetic without mutations β chains of .add(), .subtract(), and .with() are referentially transparent, so they are safe to share across async tasks and easy to reason about.
import { Temporal } from '@js-temporal/polyfill';
const start = Temporal.PlainDate.from('2026-01-31');
// .add returns a NEW instance; `start` is never mutated
const oneMonthLater = start.add({ months: 1 }); // Jan 31 + 1 month -> clamps
console.log(start.toString()); // '2026-01-31' (unchanged)
console.log(oneMonthLater.toString()); // '2026-02-28' β overflow:'constrain' (the default) clamps to month end
// Make the clamp explicit, or refuse it
const rejected = () => start.add({ months: 1 }, { overflow: 'reject' }); // throws: no Feb 31
The overflow policy is the key decision in calendar arithmetic. 'constrain' (the default) clamps out-of-range results to the nearest valid value β Jan 31 + 1 month becomes Feb 28. 'reject' throws a RangeError instead, which is what you want for financial or legal logic where a silently clamped date is a correctness bug. The month-end overflow case is subtle enough to have its own walkthrough: see add months to a date without overflow using Temporal.
Differences run the other direction with .until() and .since(), which return a Temporal.Duration:
import { Temporal } from '@js-temporal/polyfill';
const due = Temporal.PlainDate.from('2026-12-25');
const now = Temporal.PlainDate.from('2026-06-19');
// largestUnit controls the shape of the result Duration
const gap = now.until(due, { largestUnit: 'months' });
console.log(`${gap.months}mo ${gap.days}d`); // '6mo 6d'
3. ZonedDateTime & DST-Aware Logic
ZonedDateTime is where Temporal earns its keep. It carries an absolute instant and an IANA zone, so it can answer both "what moment is this?" and "what does the wall clock read?" β and it knows the DST rules to keep those consistent. The critical, non-obvious rule: adding calendar units keeps the wall clock; adding time units adds absolute elapsed time. Across a spring-forward boundary these diverge, and that divergence is exactly correct.
import { Temporal } from '@js-temporal/polyfill';
// The night the US springs forward: 02:00 -> 03:00 on 2026-03-08
const before = Temporal.ZonedDateTime.from(
'2026-03-07T22:30:00-05:00[America/New_York]'
);
// Calendar add: "same time tomorrow" β wall clock stays 22:30, but 23 real hours pass
const wall = before.add({ days: 1 });
console.log(wall.toString()); // '2026-03-08T22:30:00-04:00[America/New_York]'
// Absolute add: 24 real hours later β wall clock lands at 23:30 because an hour vanished
const absolute = before.add({ hours: 24 });
console.log(absolute.toString()); // '2026-03-08T23:30:00-04:00[America/New_York]'
When you construct a ZonedDateTime from a wall-clock value, DST creates two failure modes that disambiguation resolves. During fall-back, a wall time occurs twice (ambiguous); during spring-forward, a wall time never occurs (a gap). The policy decides which instant you get:
import { Temporal } from '@js-temporal/polyfill';
// Fall-back: 2026-11-01 01:30 happens twice in America/New_York
const ambiguous = Temporal.PlainDateTime.from('2026-11-01T01:30:00');
const first = ambiguous.toZonedDateTime('America/New_York', { disambiguation: 'earlier' });
const second = ambiguous.toZonedDateTime('America/New_York', { disambiguation: 'later' });
console.log(first.offset); // '-04:00' (EDT β the first 01:30)
console.log(second.offset); // '-05:00' (EST β the second 01:30, an hour of real time later)
console.log(first.equals(second)); // false β same wall time, different instants
// 'reject' refuses to guess β use it for scheduling where a wrong hour is a real bug
const safe = () =>
Temporal.PlainDateTime.from('2026-03-08T02:30:00') // this time never exists (spring-forward gap)
.toZonedDateTime('America/New_York', { disambiguation: 'reject' }); // throws RangeError
The four values: 'compatible' (default β matches legacy Date, picks the later instant on overlap and pushes forward through gaps), 'earlier', 'later', and 'reject' (throw on any ambiguity or gap). For full coverage β comparing zoned values, converting to and from Instant, and recurring-event scheduling that survives DST β see working with ZonedDateTime objects. To measure elapsed time correctly across a transition, take the difference of two ZonedDateTimes; it computes on the underlying instants:
import { Temporal } from '@js-temporal/polyfill';
const a = Temporal.ZonedDateTime.from('2026-03-07T23:00:00-05:00[America/New_York]');
const b = Temporal.ZonedDateTime.from('2026-03-08T03:30:00-04:00[America/New_York]');
// .until uses absolute math, so the spring-forward hour is not double-counted
const elapsed = a.until(b, { largestUnit: 'hours' });
console.log(elapsed.hours, elapsed.minutes); // 3 30 β 3.5 real hours, though the wall clock moved 4.5
4. Calendars, Durations & i18n Formatting
Temporal is calendar-aware at the type level: every PlainDate and ZonedDateTime carries a calendarId, defaulting to 'iso8601'. Switching calendars reinterprets the same instant in another system β essential for Japanese eras, the Hijri calendar, and other non-Gregorian rendering covered in calendar systems and era handling.
import { Temporal } from '@js-temporal/polyfill';
const iso = Temporal.PlainDate.from('2026-06-19'); // ISO/Gregorian
// withCalendar reinterprets the SAME day in another calendar β no instant changes
const japanese = iso.withCalendar('japanese');
console.log(japanese.eraYear, japanese.era); // 8 'reiwa' β Reiwa 8
const hijri = iso.withCalendar('islamic-umalqura');
console.log(`${hijri.year}-${hijri.month}-${hijri.day}`); // Hijri fields for the same day
Temporal.Duration is its own type β a portable amount of time, not a moment. Durations from .until()/.since() can be re-balanced, added, and rounded, but because months and days have variable length they need a reference point (relativeTo) to round between calendar units safely. The dedicated Temporal duration arithmetic guide covers adding, subtracting, and balancing them.
import { Temporal } from '@js-temporal/polyfill';
const d = Temporal.Duration.from({ hours: 50, minutes: 30 });
// Re-balance into days+hours: 50h30m has no fixed day count without a calendar anchor,
// so rounding across days/months requires relativeTo
const balanced = d.round({ largestUnit: 'days', relativeTo: Temporal.PlainDate.from('2026-06-19') });
console.log(balanced.days, balanced.hours, balanced.minutes); // 2 2 30
For display, prefer the value's own toLocaleString, which delegates to Intl and lets you pass an explicit timeZone so server rendering never drifts to the host zone. Cache the formatter when looping over many values β constructing Intl.DateTimeFormat is expensive. The broader formatting and caching playbook lives in the Intl & legacy Date patterns guide.
import { Temporal } from '@js-temporal/polyfill';
// Build the formatter ONCE and reuse it β construction dominates the cost
const fmt = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'Europe/Berlin', // explicit β never trust the host zone on a server
});
const zdt = Temporal.ZonedDateTime.from('2026-06-19T14:30:00+02:00[Europe/Berlin]');
// toInstant -> legacy Date is the bridge into a cached Intl formatter
console.log(fmt.format(new Date(zdt.epochMilliseconds)));
Gotchas & Anti-Patterns
- Tagging timezone-free data with a zone. A birthday stored as
ZonedDateTimereads as the previous day for users west of where it was created. Fix: model dates asPlainDate, times asPlainTime; only attach a zone for a real moment in a real place. - Confusing
.add({ days })with.add({ hours }). They differ by an hour across DST β calendar units preserve the wall clock, time units add absolute time. Fix: add calendar units for "same time next day"; add time units for "N hours of real elapsed time." - Letting
overflow:'constrain'silently clamp. Jan 31 + 1 month becoming Feb 28 is invisible until a billing date is wrong. Fix: pass{ overflow: 'reject' }wherever a clamped date is a correctness bug. - Ignoring
disambiguationon DST construction. The default'compatible'guesses an instant for ambiguous/nonexistent wall times. Fix: pass{ disambiguation: 'reject' }for scheduling and surface the conflict to the user. - Calling a nonexistent
Temporal.Duration.between(). It does not exist. Fix: usestart.until(end)orend.since(start)on the value, not theDurationclass. - Rounding a
Durationacross days/months withoutrelativeTo. Months and days have no fixed length, so the call throws. Fix: passrelativeToaPlainDateorZonedDateTimeanchor. - Trusting
Intlβ¦resolvedOptions().timeZoneon a server. Ephemeral containers default to UTC or drift. Fix: inject the zone via config/header and pass it explicitly to every formatter and conversion.
Testing Strategy
Date bugs hide until a test runs in a different timezone. The cheapest insurance is a TZ matrix: run your suite under several zones so any reliance on the host zone fails loudly in CI rather than silently in production.
# Run the suite under several host zones β a passing UTC run hides host-zone bugs
TZ=UTC npm test
TZ=America/New_York npm test # negative offset + DST
TZ=Asia/Kolkata npm test # half-hour offset, no DST
TZ=Pacific/Chatham npm test # 45-minute offset, the nastiest edge
Pin the specific transition moments β they are where wall-clock and absolute math diverge:
| Scenario | Input | Expected |
|---|---|---|
| Spring-forward, wall add | 2026-03-07T22:30-05:00[America/New_York].add({days:1}) |
2026-03-08T22:30:00-04:00 (offset flips) |
| Spring-forward, absolute add | same value .add({hours:24}) |
2026-03-08T23:30:00-04:00 (wall clock +1h) |
| Nonexistent wall time | 02:30 on 2026-03-08, disambiguation:'reject' |
RangeError |
| Fall-back, earlier vs later | 2026-11-01T01:30 in NY |
offsets -04:00 vs -05:00, not .equals() |
| Month-end overflow, reject | 2026-01-31.add({months:1},{overflow:'reject'}) |
RangeError |
| Month-end overflow, constrain | same with default overflow | 2026-02-28 |
| Elapsed across DST | 23:00 to next-day 03:30 NY, .until hours |
3h 30m (not 4h 30m) |
# .github/workflows/test.yml β fail the build if any host zone breaks date logic
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
TZ: [UTC, America/New_York, Asia/Kolkata, Pacific/Chatham]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
env:
TZ: $ # inject the host zone per matrix leg
Frequently Asked Questions
Is the Temporal API ready for production use?
Yes, with @js-temporal/polyfill. The proposal reached TC39 Stage 4 (ES2026) and ships natively in current Chrome and Firefox, with Safari finishing implementation. Use the polyfill at a pinned version until every target environment has native support; the API surface is identical, so removing it later is a no-op.
When should I use .add({ days }) versus .add({ hours }) on a ZonedDateTime?
Use calendar units (days, months, years) for "the same wall-clock time later" β Temporal preserves the local time and lets the offset shift across DST. Use time units (hours, minutes, seconds) for "N units of real elapsed time." Across a spring-forward boundary the two differ by exactly one hour, and that is the intended behavior.
What is the difference between disambiguation and overflow?
disambiguation resolves wall-clock times that are ambiguous or nonexistent because of DST ('earlier', 'later', 'compatible', 'reject'). overflow resolves calendar fields that are out of range, such as February 31 ('constrain' clamps, 'reject' throws). They address different problems and are passed independently.
How do I measure elapsed time without DST throwing off the result?
Call .until() or .since() on two ZonedDateTime (or Instant) values. The difference is computed on the underlying absolute instants, so a spring-forward hour is never double-counted. The Temporal.Duration.between() method does not exist β use the instance methods.
Can I mix Temporal with existing Date objects during a migration?
Yes. Bridge with Temporal.Instant.fromEpochMilliseconds(date.getTime()) going in, and new Date(zdt.epochMilliseconds) coming out for a cached Intl.DateTimeFormat. Convert at the system boundary and keep Temporal types in your core logic.
Does Temporal replace Intl.DateTimeFormat?
No β they complement each other. Temporal owns the temporal math and type safety; Intl owns locale-aware formatting, calendar rendering, and pluralization. Construct the formatter once, pass an explicit timeZone, and feed it Temporal values via toLocaleString or a converted Date.
Related
- Getting started with the Temporal API β install, configure, and the type-selection rules.
- Working with ZonedDateTime objects β comparison, instant conversion, DST-safe scheduling.
- Date arithmetic without mutations β immutable
.add/.subtract/.withand overflow policy. - Calendar systems and era handling β Japanese eras, Hijri, and non-Gregorian rendering.
- Temporal duration arithmetic β adding, balancing, and rounding
Duration. - JavaScript Date fundamentals and Intl & legacy Date patterns β the companion guides.