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
The mental shift Temporal asks of you is to stop thinking of "a date" as one thing and start thinking of it as several distinct things that legacy Date unfortunately smashed together. There is absolute time β a point on the physical timeline, the same instant everywhere, which Temporal models as Instant. There is wall-clock time in a place β what a clock on the wall reads, which only means something paired with a zone, modelled as ZonedDateTime. And there is civil time with no zone at all β a calendar date or a time-of-day considered in the abstract, modelled as PlainDate, PlainTime, and PlainDateTime. Almost every date bug in legacy code traces back to using one of these where another was meant, because Date let you.
Choosing the right type is therefore the highest-leverage decision in any date code, and it is usually obvious once you ask a single question: does this value need a zone to be meaningful? A log timestamp does not β it is an instant. A meeting does β it is zoned. A birthday does not β it is a civil date. Getting that answer right at the point a value enters your system, and keeping the type honest thereafter, eliminates whole categories of bug before they can be written. The rest of this guide is really the elaboration of that one habit across arithmetic, zones, calendars, and serialization.
| 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
The most damaging anti-pattern is "instant laundering" β taking a zoned or civil value, routing it through a bare Instant or a legacy Date, and reading it back, which silently discards the zone or the zoneless-ness the value depended on. Once a ZonedDateTime has been collapsed to a UTC instant, no downstream code can recover the wall clock the user actually meant, and the resulting time is wrong at every DST boundary. The fix is to keep the richest appropriate type end to end and convert only at genuine interop boundaries, never as an incidental step in the middle of a pipeline.
A second recurring trap is mixing calendar-unit and clock-unit reasoning: using { hours: 24 } where { days: 1 } was meant, or approximating a month as 30 days. These produce results that are correct most of the year and wrong exactly on the days that matter β transition days and month-ends β which is the worst failure profile because it survives casual testing. A third is comparing formatted strings instead of values, which sorts lexicographically and scrambles chronological order. Each of these is a case of reaching past the type system for a shortcut the types were designed to make unnecessary; the discipline is to let the types do the work.
- 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
The single most valuable testing habit for date code is to run the suite under several TZ environment values and assert the results do not change for any calculation that is supposed to be zone-independent. A civil calculation β days between two PlainDates, the weekday of a date, a month grid β must produce identical output whether the test runs in UTC, America/Los_Angeles, or Asia/Kolkata. A test that passes only in your local zone is not passing; it is coincidentally not-failing, and it will break in CI or production the moment the host zone differs. Making zone-independence an explicit, asserted property catches the largest class of date bugs early.
The second habit is to test at the boundaries where date logic actually breaks: daylight-saving transitions (both spring-forward gaps and fall-back overlaps), month-ends and leap days, year boundaries and ISO week-year boundaries, and the exact instant a countdown or expiry crosses zero. Interior cases rarely fail; boundaries are where the off-by-one and the DST drift live. The third habit is to inject the clock rather than reading it, so that any logic depending on "now" can be frozen to a known instant and asserted deterministically. Together these three β zone-independence, boundary cases, and an injected clock β cover the overwhelming majority of the ways date code goes wrong.
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.
How do I choose between Instant, ZonedDateTime, and PlainDate?
Ask whether the value needs a time zone to be meaningful. If it is a point on the physical timeline with no place attached β a log timestamp, an event ordering key β use Instant. If it is a time someone reads on a local clock β a meeting, a reminder, store hours β use ZonedDateTime so the zone travels with it. If it is a civil date or time considered in the abstract β a birthday, a contract date, a calendar grid β use PlainDate/PlainTime, which carry no zone and stay identical everywhere. Getting this right where the value enters the system prevents most date bugs.
What is the most common source of date bugs Temporal prevents?
Using one kind of time where another was meant β the confusion legacy Date encouraged by being a single type. The classic failure is 'instant laundering': collapsing a zoned or civil value through a UTC instant or a Date and losing the zone or zoneless-ness it depended on, which then produces wrong times at every daylight-saving boundary. Temporal's separate types make the intended meaning explicit, so the compiler and the API stop you from silently discarding the information.
How should I test date logic to catch time-zone bugs?
Run your test suite under several TZ environment values and assert that zone-independent calculations produce identical results in every one β a test that passes only in your local zone is coincidentally not-failing, not passing. Add boundary cases at daylight-saving transitions, month-ends, leap days, and year/ISO-week boundaries, since that is where date logic actually breaks. And inject the clock rather than reading Temporal.Now directly, so anything depending on 'now' can be frozen and asserted deterministically.
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.