No more new Date() surprises, no more moment.js, no more timezone nightmares. The TC39 Temporal API ships in 2026 with PlainDate, ZonedDateTime, Duration, and calendar support that actually works — here’s the complete migration guide from the Date object you’ve been fighting for years.
The JavaScript Date object was created in ten days in 1995. Brendan Eich, under orders to “make it like Java,” copied the API directly from java.util.Date. Within two years, Java had deprecated most of that class’s own methods because the design was already considered a mistake. JavaScript developers have been living with that mistake for 31 years.
That ends now.
The TC39 Temporal proposal hit Stage 4 in late 2024, RFC 9557 (the wire format for timezone-aware strings) was ratified in October 2024, and browsers began shipping immediately after. Firefox 139 shipped Temporal by default in May 2025, followed by Chrome 144 in January 2026. Two major browsers. Production-ready with a polyfill for the rest.
This is the complete guide: what was broken, what Temporal fixes, every type explained, and the migration patterns you need.
What Was Actually Wrong With Date
Before the solution, the specific failures — because “Date is broken” is vague, but the actual bugs are specific and painful:
Bug 1: Date Strings Parse Inconsistently
// This should give you January 15, 2026 in your local timezone
new Date('2026-01-15')
// What it actually gives you: January 15 at midnight UTC
// Which is January 14 in any timezone behind UTC
// Users in New York, Los Angeles, Mumbai see January 14
// The "fix" that isn't a fix
new Date('2026-01-15T00:00:00') // now local midnight — consistent, but confusing
// What you wanted: a date, not a point in time
// Date has no concept of "just a date" — everything is a timestamp
Bug 2: Month Indexing Is 0-Based
// December is month 11. January is month 0.
// Every developer has shipped this bug at least once.
new Date(2026, 11, 25) // December 25 (correct, counterintuitively)
new Date(2026, 12, 25) // January 25, 2027 (wrong but no error)
// No warning. No validation. Just wrong dates.
Bug 3: Arithmetic Doesn’t Respect DST
// Adding 24 hours is not the same as adding 1 day during DST transitions
const before = new Date('2026-03-29T10:00:00+00:00[Europe/London]')
const after = new Date(before.getTime() + 24 * 60 * 60 * 1000)
// after is 11:00 the next day — not 10:00 — because DST shifted clocks forward
// You wanted "same time tomorrow" and got "25 hours later"
Bug 4: Mutation and Comparison
// Date objects are mutable
const date1 = new Date(2026, 0, 1)
const date2 = date1
date2.setMonth(5)
console.log(date1.getMonth()) // 5 — date1 was mutated too!
// No equality operator
const a = new Date(2026, 0, 1)
const b = new Date(2026, 0, 1)
a === b // false — different objects
a == b // false — compares references, not values
// You have to compare .getTime() to compare dates
Bug 5: No Duration Type
// Calculating time between dates
const start = new Date(2026, 0, 1)
const end = new Date(2026, 5, 15)
const diff = end - start // milliseconds — a raw number
// How many months is that? How many weeks?
// You do the math manually. Every time.
The Temporal Mental Model: Eight Types, Each With One Job
The Temporal API has eight types, and the key insight is that functionality is not crammed into a single object — each type has a clear, specific purpose.
Temporal.PlainDate → A calendar date. No time. No timezone. "2026-06-15"
Temporal.PlainTime → A time of day. No date. No timezone. "14:30:00"
Temporal.PlainDateTime → Date + time. No timezone. "2026-06-15T14:30:00"
Temporal.ZonedDateTime → Date + time + timezone. The complete picture.
Temporal.Instant → An exact point in time (like a Unix timestamp). Immutable.
Temporal.Duration → An amount of time. "3 days and 4 hours".
Temporal.PlainYearMonth → Year and month only. "2026-06"
Temporal.PlainMonthDay → Month and day only. "06-15" (for recurring dates)
Temporal.Now → Factory for getting the current moment as any type.
The rule: use the least-specific type that works for your use case. A birthday is a PlainDate — it has no time and no timezone. A meeting is a ZonedDateTime — it exists at a specific moment in a specific timezone. A recurring daily task start time is a PlainTime.
Temporal.PlainDate: Dates Without the Timestamp Baggage
// Creation
const today = Temporal.Now.plainDateISO()
const birthday = Temporal.PlainDate.from('1990-04-15')
const holiday = Temporal.PlainDate.from({ year: 2026, month: 12, day: 25 })
// Access components — no 0-based months
console.log(birthday.year) // 1990
console.log(birthday.month) // 4 (April — human-readable, 1-based)
console.log(birthday.day) // 15
console.log(birthday.dayOfWeek) // 1 = Monday through 7 = Sunday (ISO)
// Arithmetic — returns a new PlainDate (immutable)
const nextWeek = today.add({ weeks: 1 })
const lastMonth = today.subtract({ months: 1 })
const twoYearsAgo = today.subtract({ years: 2 })
// Arithmetic handles edge cases correctly
const jan31 = Temporal.PlainDate.from('2026-01-31')
const feb = jan31.add({ months: 1 })
console.log(feb.toString()) // '2026-02-28' — clamped to last valid day
// Date does: new Date(2026, 1, 31) → March 3, 2026 (overflows)
// Temporal does: clamped to Feb 28
// Comparison
const a = Temporal.PlainDate.from('2026-01-01')
const b = Temporal.PlainDate.from('2026-06-15')
a.equals(b) // false (value equality, not reference)
Temporal.PlainDate.compare(a, b) // -1 (a is before b)
a.until(b).toString() // 'P5M14D' (5 months and 14 days)
b.since(a).total({ unit: 'days' }) // 165 (total days between them)
// Check if a date is in a range
function isInRange(date, start, end) {
return (
Temporal.PlainDate.compare(date, start) >= 0 &&
Temporal.PlainDate.compare(date, end) <= 0
)
}
Temporal.ZonedDateTime: The Full Picture
Use ZonedDateTime when you need a specific moment in time in a specific timezone — meetings, appointments, event start times, database timestamps.
// Create from ISO string with timezone annotation (RFC 9557 format)
const meeting = Temporal.ZonedDateTime.from(
'2026-06-15T14:00:00[America/New_York]'
)
// Create from components
const concert = Temporal.ZonedDateTime.from({
year: 2026,
month: 8,
day: 20,
hour: 20,
minute: 0,
timeZone: 'Asia/Kolkata',
})
// Current time in a specific timezone
const tokyoNow = Temporal.Now.zonedDateTimeISO('Asia/Tokyo')
const mumbaiNow = Temporal.Now.zonedDateTimeISO('Asia/Kolkata')
// Convert between timezones — no library needed
const newYorkMeeting = Temporal.ZonedDateTime.from(
'2026-06-15T09:00:00[America/New_York]'
)
const toMumbai = newYorkMeeting.withTimeZone('Asia/Kolkata')
console.log(toMumbai.toString())
// '2026-06-15T18:30:00+05:30[Asia/Kolkata]'
// DST-aware arithmetic — the thing Date got wrong
const before = Temporal.ZonedDateTime.from(
'2026-03-28T10:00:00[Europe/London]'
)
const nextDay = before.add({ days: 1 })
// nextDay is 2026-03-29T10:00:00+01:00[Europe/London]
// Still 10am wall-clock time — even though DST moved clocks forward
// Date would have given you 11am
// The difference between adding hours and adding days
const plus24Hours = before.add({ hours: 24 }) // 11am (raw hours)
const plus1Day = before.add({ days: 1 }) // 10am (wall-clock day)
// These are different. Temporal makes this distinction explicit.
// Format with Intl — locale-aware, zero config
const formatted = meeting.toLocaleString('en-IN', {
dateStyle: 'full',
timeStyle: 'short',
})
// "Monday, June 15, 2026 at 11:30 PM" (converted to IST automatically)
Temporal.Instant: The Unix Timestamp Done Right
Instant is like a Unix timestamp — an exact point in universal time, timezone-agnostic. Use it for database storage, ordering events, comparing moments across timezones.
// Get current instant
const now = Temporal.Now.instant()
console.log(now.toString()) // '2026-06-15T09:00:00.000000000Z'
// From a Unix timestamp (milliseconds)
const fromMs = Temporal.Instant.fromEpochMilliseconds(Date.now())
// From ISO string
const event = Temporal.Instant.from('2026-06-15T14:00:00Z')
// Compare two instants (ordering)
const a = Temporal.Instant.from('2026-01-01T00:00:00Z')
const b = Temporal.Instant.from('2026-06-01T00:00:00Z')
Temporal.Instant.compare(a, b) // -1 (a is earlier)
// Convert to a ZonedDateTime for display
const instant = Temporal.Instant.from('2026-06-15T14:00:00Z')
const inMumbai = instant.toZonedDateTimeISO('Asia/Kolkata')
console.log(inMumbai.toString())
// '2026-06-15T19:30:00+05:30[Asia/Kolkata]'
// The relationship: Instant is "when", ZonedDateTime is "when + where"
// Use Instant for storage and comparison
// Use ZonedDateTime for display and user-facing logic
Temporal.Duration: Time Arithmetic That Makes Sense
// Create durations
const twoWeeks = new Temporal.Duration(0, 0, 2) // years, months, weeks
const threeHours = Temporal.Duration.from({ hours: 3 })
const deadline = Temporal.Duration.from('P30DT4H') // ISO 8601 duration string
// Use in arithmetic
const today = Temporal.Now.plainDateISO()
const futureDate = today.add(Temporal.Duration.from({ months: 3, days: 15 }))
// Calculate duration between two dates
const start = Temporal.PlainDate.from('2026-01-01')
const end = Temporal.PlainDate.from('2026-06-15')
const duration = start.until(end, { largestUnit: 'months' })
console.log(duration.months) // 5
console.log(duration.days) // 14
console.log(duration.toString()) // 'P5M14D'
// Total in a single unit
const totalDays = start.until(end).total({ unit: 'days' }) // 165
const totalWeeks = start.until(end).total({ unit: 'weeks' }) // 23.57...
// Negate a duration
const negated = Temporal.Duration.from({ hours: 5 }).negated()
// { hours: -5 }
// Duration between two ZonedDateTimes (accounts for DST)
const meetingStart = Temporal.ZonedDateTime.from('2026-06-15T09:00:00[Asia/Kolkata]')
const meetingEnd = Temporal.ZonedDateTime.from('2026-06-15T11:30:00[Asia/Kolkata]')
const meetingDuration = meetingStart.until(meetingEnd)
console.log(meetingDuration.toString()) // 'PT2H30M'
Temporal.Now: Your Entry Point
// Get the current moment as any type
Temporal.Now.instant() // exact point in time (UTC)
Temporal.Now.zonedDateTimeISO() // current date+time in local timezone
Temporal.Now.zonedDateTimeISO('Asia/Tokyo') // current date+time in Tokyo
Temporal.Now.plainDateISO() // today (no time, no timezone)
Temporal.Now.plainDateISO('America/New_York') // today in New York
Temporal.Now.plainTimeISO() // current time (no date, no timezone)
Temporal.Now.plainDateTimeISO() // current date and time (no timezone)
Specialized Types for Specific Use Cases
Temporal.PlainYearMonth — Monthly Views
// Perfect for calendar month views, billing periods, monthly reports
const thisMonth = Temporal.Now.plainDateISO().toPlainYearMonth()
const nextMonth = thisMonth.add({ months: 1 })
console.log(thisMonth.toString()) // '2026-06'
console.log(thisMonth.daysInMonth) // 30
console.log(thisMonth.inLeapYear) // false
// Generate all months in a year
const months = Array.from({ length: 12 }, (_, i) =>
Temporal.PlainYearMonth.from({ year: 2026, month: i + 1 })
)
Temporal.PlainMonthDay — Recurring Dates
// Birthdays, holidays, anniversaries — recurring every year
const christmas = Temporal.PlainMonthDay.from('12-25')
const diwali2026 = Temporal.PlainMonthDay.from({ month: 10, day: 20 })
// Get the date in a specific year
const christmas2026 = christmas.toPlainDate({ year: 2026 })
const christmas2027 = christmas.toPlainDate({ year: 2027 })
// Check if today is someone's birthday
const birthday = Temporal.PlainMonthDay.from('04-15')
const today = Temporal.Now.plainDateISO()
const isToday = birthday.equals(today.toPlainMonthDay())
Real-World Patterns
Countdown Timer
function getCountdown(targetDateString) {
const target = Temporal.ZonedDateTime.from(targetDateString)
const now = Temporal.Now.zonedDateTimeISO(target.timeZoneId)
const diff = now.until(target, {
largestUnit: 'days',
smallestUnit: 'seconds',
})
if (Temporal.ZonedDateTime.compare(now, target) >= 0) {
return { expired: true }
}
return {
expired: false,
days: diff.days,
hours: diff.hours,
minutes: diff.minutes,
seconds: diff.seconds,
}
}
// Usage
const countdown = getCountdown('2026-12-31T23:59:59[Asia/Kolkata]')
// { days: 199, hours: 14, minutes: 52, seconds: 3, expired: false }
Working Days Calculator
// Calculate business days between two dates
function workingDaysBetween(start, end) {
let count = 0
let current = start
while (Temporal.PlainDate.compare(current, end) < 0) {
// dayOfWeek: 1=Monday, 7=Sunday
if (current.dayOfWeek <= 5) { // Monday to Friday
count++
}
current = current.add({ days: 1 })
}
return count
}
const startDate = Temporal.PlainDate.from('2026-06-01')
const endDate = Temporal.PlainDate.from('2026-06-30')
const workDays = workingDaysBetween(startDate, endDate)
// 22 working days in June 2026
Scheduling Across Timezones
// Find a meeting time that works for multiple timezones
function findMeetingSlot(localTime, timezones) {
const base = Temporal.ZonedDateTime.from(
`${Temporal.Now.plainDateISO()}T${localTime}[UTC]`
)
return timezones.map(tz => {
const local = base.withTimeZone(tz)
return {
timezone: tz,
localTime: local.toLocaleString('en', { timeStyle: 'short' }),
hour: local.hour,
isBusinessHours: local.hour >= 9 && local.hour < 18,
isDayOff: local.dayOfWeek > 5,
}
})
}
findMeetingSlot('14:00', [
'Asia/Kolkata',
'America/New_York',
'Europe/London',
'Asia/Tokyo',
])
// Shows local time for each timezone + whether it's business hours
Parsing and Formatting
// Temporal types parse strict ISO 8601 — no ambiguity
const date = Temporal.PlainDate.from('2026-06-15') // ✓
const zdt = Temporal.ZonedDateTime.from('2026-06-15T14:00:00[Asia/Kolkata]') // ✓
// Formatting uses Intl — locale-aware
date.toLocaleString('en-IN', { dateStyle: 'full' })
// "Monday, June 15, 2026"
date.toLocaleString('hi-IN', { dateStyle: 'long' })
// "15 जून 2026"
zdt.toLocaleString('ja-JP', { dateStyle: 'short', timeStyle: 'short' })
// "2026/06/15 17:30"
// For ISO string output (e.g., API responses)
date.toString() // '2026-06-15'
zdt.toString() // '2026-06-15T14:00:00+05:30[Asia/Kolkata]'
Browser Support and the Polyfill Strategy
Temporal ships natively in Firefox 139+ (since May 2025) and Chrome 144+ (since January 2026), as well as Edge and Deno. Safari doesn’t support it yet — it’s in Safari Technical Preview behind a flag, but not available to users in the stable release. Because of that, you’ll want a polyfill for production use today.
# Option 1: Official polyfill maintained by the proposal champions
npm install @js-temporal/polyfill
# Option 2: Smaller, faster alternative by the FullCalendar team
# Does not depend on BigInt internally
npm install temporal-polyfill
// In your app entry point (main.js / index.ts)
import { Temporal } from 'temporal-polyfill'
// Or with globalThis for transparent usage
import 'temporal-polyfill/global'
// Now Temporal is available globally — same API as native
// TypeScript types
import type { Temporal } from '@js-temporal/polyfill'
// Or install types separately:
// npm install --save-dev @types/temporal
Migration: The Common Patterns
Date → Temporal.PlainDate
// ✗ Old — timezone-dependent, 0-based months
const date = new Date(2026, 5, 15) // June 15? Or July?
const year = date.getFullYear()
const month = date.getMonth() + 1 // manual +1 every time
// ✓ New
const date = Temporal.PlainDate.from({ year: 2026, month: 6, day: 15 })
const year = date.year // 2026
const month = date.month // 6 (June, no +1 needed)
Date.now() → Temporal.Now
// ✗ Old
const now = new Date()
const dateStr = now.toISOString().split('T')[0] // extract date from timestamp
// ✓ New
const today = Temporal.Now.plainDateISO()
const dateStr = today.toString() // '2026-06-15'
Date arithmetic → Temporal arithmetic
// ✗ Old — manual millisecond math
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000)
const nextYear = new Date(today)
nextYear.setFullYear(today.getFullYear() + 1)
// ✓ New — readable, DST-aware
const tomorrow = Temporal.Now.plainDateISO().add({ days: 1 })
const nextYear = Temporal.Now.plainDateISO().add({ years: 1 })
Timezone handling
// ✗ Old — no standard timezone support in Date
const options = { timeZone: 'Asia/Tokyo', hour: '2-digit', minute: '2-digit' }
const tokyoTime = new Date().toLocaleTimeString('en', options)
// Works for formatting, not for arithmetic
// ✓ New — full timezone-aware arithmetic
const tokyoNow = Temporal.Now.zonedDateTimeISO('Asia/Tokyo')
const nextHour = tokyoNow.add({ hours: 1 }) // DST-aware
const inNY = tokyoNow.withTimeZone('America/New_York')
date-fns / moment.js → Temporal
// date-fns format
import { format } from 'date-fns'
format(new Date(), 'yyyy-MM-dd')
// Temporal (zero import, zero bundle cost)
Temporal.Now.plainDateISO().toString()
// date-fns addDays
import { addDays } from 'date-fns'
addDays(new Date(), 30)
// Temporal
Temporal.Now.plainDateISO().add({ days: 30 })
// date-fns differenceInDays
import { differenceInDays } from 'date-fns'
differenceInDays(end, start)
// Temporal
start.until(end).total({ unit: 'days' })
Quick Reference: Type Selection Guide
What do you need? Use
─────────────────────────────────────────────────────────
A date (birthday, deadline, holiday) PlainDate
A time of day (alarm, recurring task) PlainTime
Date + time, no timezone PlainDateTime
Date + time + timezone (meeting, event) ZonedDateTime
Store in DB / compare across timezones Instant
An amount of time (duration of a session) Duration
Year and month (billing period, calendar view) PlainYearMonth
Recurring annual date (birthday, anniversary) PlainMonthDay
Current date/time of any type Temporal.Now.*
Final Thoughts
The Date object was a 31-year mistake shipped in ten days. Temporal is the deliberate, carefully designed replacement that TC39, browser vendors, and the IETF spent nine years getting right.
Temporal hit Stage 4 in late 2024 and browsers began shipping immediately after RFC 9557 was ratified in October 2024, which unblocked browser implementations that had been ready in principle for months.
The design philosophy is the thing most worth internalising: eight types, each doing one job clearly. PlainDate has no hidden timezone. Instant has no calendar ambiguity. Duration is a first-class value, not a number of milliseconds. The type you choose communicates intent — to the runtime, to your colleagues, and to yourself six months later.
The migration doesn’t have to be all-or-nothing. Start by replacing new Date() for date-only use cases with Temporal.PlainDate. Replace timezone formatting hacks with ZonedDateTime. Remove date-fns imports for anything Temporal now covers natively.
The polyfill is small, the API is stable, and Safari support is coming. There’s no reason to keep fighting Date.
