
Published July 16, 2026 · 8 min read
Dates look simple until an application crosses a month boundary, a daylight-saving transition, or a user’s time zone. Then code that passed every ordinary test can move an appointment, mutate a value shared by another function, or label “today” with yesterday’s date.
The root problem is not that developers cannot work with dates. It is that JavaScript’s Date asks one mutable object to represent several different ideas. Temporal replaces that guesswork with explicit types for calendar dates, wall-clock times, exact instants, and time-zone-aware events.
This guide starts with failures you can reproduce, then replaces each one with Temporal code. The examples require native Temporal support or a suitable polyfill; check the adoption section before running them in your target environment.
Imagine a helper that calculates a follow-up date. setDate() sounds like a calculation, but it changes the original object in place:
function getNextWeek(date) {
date.setDate(date.getDate() + 7)
return date
}
const invoiceDate = new Date(2026, 5, 1)
const followUpDate = getNextWeek(invoiceDate)
console.log(invoiceDate === followUpDate) // true
console.log(invoiceDate.getDate()) // 8 — the original was changedThis becomes dangerous when invoiceDate is also stored in state, cached, or passed to another calculation. The helper has changed data outside its own scope.
You can clone a Date before every setter call, but correctness then depends on every developer remembering that convention. MDN documents that setDate() changes the object in place, including its daylight-saving behavior.
The numeric Date constructor uses months from 0 to 11, while days start at 1:
const releaseDate = new Date(2026, 2, 15)
console.log(releaseDate.getMonth()) // 2
// The date is March 15, not February 15.This is valid JavaScript, not an exception. The Date constructor reference defines monthIndex as zero-based, so a value copied from a form, API, or database can be shifted by one month without looking suspicious in code review.
A Date stores an instant: a number of milliseconds since the Unix epoch. Its local getters interpret that instant using the host machine’s time zone, and its UTC getters use UTC. The object itself does not remember that a booking belongs to America/New_York or Asia/Kolkata.
const startsAt = new Date("2026-03-15T13:30:00Z")
console.log(startsAt.toISOString()) // 2026-03-15T13:30:00.000Z
console.log(startsAt.timeZone) // undefined
// These are not Date APIs:
// startsAt.toTimezone("America/New_York")
// new Date("2026-03-15T09:30[America/New_York]")Intl.DateTimeFormat can format a Date in a named zone, but that zone is configuration on the formatter—not part of the stored date value. If the zone matters to later arithmetic, recurrence, or persistence, your application must carry it separately.
In America/New_York, clocks jump from 01:59 to 03:00 on March 8, 2026. The local time 02:30 never occurs. When the host system is configured for that zone, Date silently moves the input forward:
// Run with the system time zone set to America/New_York.
const maintenance = new Date(2026, 2, 8, 2, 30)
console.log(maintenance.toString())
// Sun Mar 08 2026 03:30:00 GMT-0400 (Eastern Daylight Time)That “helpful” normalization may be wrong for scheduling. A booking system might need to reject the time and ask the user to choose another one. A background job might deliberately choose the next valid instant. Date performs the adjustment before your business rule gets a say.
Temporal does not try to make every time value interchangeable. It gives each domain meaning its own type. Choosing the right type is the most important part of using the API.
| If the business value means… | Use | Example use case |
|---|---|---|
| A calendar date without a time or zone | Temporal.PlainDate | Birthday, invoice due date, holiday |
| A wall-clock time without a date or zone | Temporal.PlainTime | Store opening time, daily reminder preference |
| A local date and time without a zone | Temporal.PlainDateTime | Appointment entered before a branch or zone is selected |
| A local date-time in a named time zone | Temporal.ZonedDateTime | Flight, support shift, scheduled webinar |
| One exact point on the global timeline | Temporal.Instant | Audit event, token expiry, server timestamp |
| A year and month | Temporal.PlainYearMonth | Credit-card expiry, monthly reporting period |
| A recurring month and day | Temporal.PlainMonthDay | Birthday recurrence, annual company event |
The constructors make those meanings visible:
const launchDate = Temporal.PlainDate.from("2026-03-15")
const openingTime = Temporal.PlainTime.from("09:30:00")
const localAppointment = Temporal.PlainDateTime.from("2026-03-15T09:30:00")Use the richer timeline types only when the domain needs them:
const newYorkAppointment = Temporal.ZonedDateTime.from(
"2026-03-15T09:30:00[America/New_York]"
)
const auditTimestamp = Temporal.Instant.from("2026-03-15T13:30:00Z")And use partial calendar types instead of inventing placeholder days or years:
const billingMonth = Temporal.PlainYearMonth.from("2026-03")
const birthday = Temporal.PlainMonthDay.from("03-15")None of the Plain types is secretly UTC. They intentionally have no time zone. Add a zone only when converting a local date-time into a real moment or when the zone is part of the business value.
Temporal arithmetic returns a new value, so a helper cannot unexpectedly rewrite its input:
const invoiceDate = Temporal.PlainDate.from("2026-06-01")
const followUpDate = invoiceDate.add({ days: 7 })
console.log(invoiceDate.toString()) // 2026-06-01
console.log(followUpDate.toString()) // 2026-06-08Months are also human-numbered. Temporal.PlainDate.from({ year: 2026, month: 3, day: 15 }) means March 15. There is no zero-based month conversion to remember.
Temporal lets the application decide what a nonexistent local time means. For a user-entered appointment, rejecting it is often safer than moving it silently:
const fields = {
year: 2026,
month: 3,
day: 8,
hour: 2,
minute: 30,
timeZone: "America/New_York",
}
Temporal.ZonedDateTime.from(fields, { disambiguation: "reject" })
// RangeError: 02:30 does not exist in this zone on this date.Temporal also supports deliberate policies for choosing the earlier or later instant when clocks repeat an hour. The TC39 time-zone documentation explains the compatible, earlier, later, and reject options.
The following replacements solve common application tasks. They are small enough to introduce at a boundary without rewriting an entire codebase.
Slicing toISOString() is a common way to produce YYYY-MM-DD, but the value is calculated in UTC:
const dateInUtc = new Date().toISOString().slice(0, 10)Near midnight, dateInUtc can be one calendar day ahead of or behind the user. Ask Temporal for a calendar date in the system zone instead:
const today = Temporal.Now.plainDateISO()
console.log(today.toString()) // For example: 2026-08-17For server code, avoid relying on the server’s system zone when the user’s zone is known:
const customerToday = Temporal.Now.plainDateISO("Asia/Kolkata")The Temporal.Now documentation describes the system-zone default and the optional named time-zone argument.
With Date, even a simple 30-day calculation needs a clone to avoid mutation:
const trialStarted = new Date(2026, 5, 1)
const trialEnds = new Date(trialStarted)
trialEnds.setDate(trialEnds.getDate() + 30)With a date-only Temporal value, the operation matches the business language:
const trialStarted = Temporal.PlainDate.from("2026-06-01")
const trialEnds = trialStarted.add({ days: 30 })
console.log(trialStarted.toString()) // 2026-06-01
console.log(trialEnds.toString()) // 2026-07-01Use PlainDate for “30 calendar days after this date.” Use an Instant or ZonedDateTime when hours, elapsed time, or a regional clock also matter.
Two Date instances representing the same instant are different objects, so strict equality returns false:
const first = new Date("2026-03-15T00:00:00Z")
const second = new Date("2026-03-15T00:00:00Z")
console.log(first === second) // false
console.log(first.getTime() === second.getTime()) // trueTemporal exposes value-based operations on the type itself:
const first = Temporal.PlainDate.from("2026-03-15")
const second = Temporal.PlainDate.from("2026-03-15")
console.log(first.equals(second)) // true
console.log(Temporal.PlainDate.compare(first, second)) // 0The same comparison function can sort date-only values. Use an explicit callback when parsing an array because Array.map passes extra callback arguments:
const dates = ["2026-03-20", "2026-03-15", "2026-03-18"].map((value) =>
Temporal.PlainDate.from(value)
)
const sortedDates = dates.toSorted(Temporal.PlainDate.compare)
console.log(sortedDates.map(String))
// ["2026-03-15", "2026-03-18", "2026-03-20"]Use .equals() when you need a boolean and .compare() when you need ordering. The PlainDate reference also documents calendar-aware arithmetic and field access.
Adding 24 * 60 * 60 * 1000 to a Date means exactly 24 elapsed hours. It does not always mean the same local time tomorrow. Across New York’s spring DST transition:
// Run with the system time zone set to America/New_York.
const start = new Date(2026, 2, 7, 12, 0)
const result = new Date(start.getTime() + 24 * 60 * 60 * 1000)
console.log(result.toString())
// Sun Mar 08 2026 13:00:00 GMT-0400 (Eastern Daylight Time)Temporal makes the desired rule part of the operation:
const start = Temporal.ZonedDateTime.from(
"2026-03-07T12:00:00-05:00[America/New_York]"
)
const tomorrowAtNoon = start.add({ days: 1 })
const exactly24HoursLater = start.add({ hours: 24 })
console.log(tomorrowAtNoon.toString())
// 2026-03-08T12:00:00-04:00[America/New_York]
console.log(exactly24HoursLater.toString())
// 2026-03-08T13:00:00-04:00[America/New_York]Only 23 elapsed hours separate start and tomorrowAtNoon, but the wall-clock time remains noon. This is correct for “deliver every day at noon.” Adding 24 hours is correct for “expire after exactly 24 hours.” Neither rule is universally better; the type and unit should reveal which rule the product requires.
The Temporal proposal has reached Stage 4, and native implementations have shipped in Firefox 139, Chrome 144, and Node.js 26. Support is still not universal across every browser and LTS server fleet, so production code should follow the environments it actually serves instead of assuming Temporal exists everywhere.
const hasNativeTemporal = typeof globalThis.Temporal !== "undefined"A boundary-first migration is usually the safest approach:
Date objects once at the application boundary.Date, epoch milliseconds, or a string.For example, an existing Date already represents an instant. Preserve that meaning first, then choose a display zone separately:
const legacyDate = new Date("2026-03-15T13:30:00Z")
const instant = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
const localView = instant.toZonedDateTimeISO("America/New_York")Locale-sensitive presentation still belongs with Intl. Recurrence rules may still require a domain library. Temporal’s job is to give the application a precise internal model so those other tools receive the correct value.
JavaScript’s Date is not useless; it is overloaded. Its mutable setters, zero-based numeric months, host-dependent local view, and silent daylight-saving normalization make business meaning easy to lose.
Temporal helps by forcing that meaning into the type. Use PlainDate for a calendar date, Instant for an exact timestamp, and ZonedDateTime when a named zone and daylight-saving rules matter. Its immutable arithmetic makes helpers safer, while explicit comparison and ambiguity policies make code easier to review and test.
Do not begin by replacing every Date. Begin at one risky boundary—bookings, billing, reminders, reports, or audit timestamps—and choose the narrowest Temporal type that tells the truth about the data.
Occasional articles on React, full-stack development, performance, and AI workflows.