Date and Time Finally Handled Right
The Date object is one of the oldest and most error-prone parts of JavaScript. The Temporal API breaks with all of its design flaws: immutable types, explicit time zone semantics, correct calendar arithmetic and a clear API, without external libraries like Moment.js or date-fns.
Table of Contents
- 1. Why the Date Object Fails
- 2. The Temporal Types: PlainDate, Instant, ZonedDateTime and More
- 3. PlainDate and PlainTime: Date Without a Time Zone
- 4. Instant: The Exact Point in Time in UTC
- 5. ZonedDateTime: Time Zone Explicit and Immutable
- 6. Date Arithmetic: Duration and Differences
- 7. Temporal vs. Date: The Direct Comparison
- 8. Formatting and Internationalization with Intl
- 9. Migrating from Date to Temporal
- 10. Summary
- 11. FAQ
1. Why the Date Object Fails
The JavaScript Date object was built in a matter of days back in 1995 and has barely seen any meaningful improvements since. Its design problems are well documented and hit every developer sooner or later: months are 0-based (January is 0, December is 11), getYear() returns the number of years since 1900, and Date objects are mutable, date.setMonth(date.getMonth() + 1) changes the original object in place. This leads to hard-to-find bugs when the same date object is passed around to multiple places.
The most serious problem, however, is the missing time zone support: Date only knows UTC and the local system time zone of the device. Every other time zone has to be simulated with manual offset calculations, an error-prone approach that ignores daylight saving transitions. That is exactly why libraries like Moment.js, Luxon and date-fns came into existence. The Temporal API makes all of that unnecessary: it is the result of years of work by the TC39 working group and addresses every known design flaw of the Date object with a modern, immutable API.
2. The Temporal Types: PlainDate, Instant, ZonedDateTime and More
The most important conceptual difference of the Temporal API compared to Date is that it separates distinct concepts into their own types. Date tries to cover every scenario with a single type, date, time, time zone, UTC timestamp, and fails at all of them. The Temporal API instead offers specialized types: Temporal.PlainDate for a date without time and time zone, Temporal.PlainTime for a time without date and time zone, Temporal.PlainDateTime for date and time without a time zone, Temporal.Instant for an exact UTC point in time, and Temporal.ZonedDateTime for the complete type with time zone.
This specialization is not an academic exercise, it forces developers to state explicitly which concept they mean. A birthday is a PlainDate, it has no time zone, because a birthday in Tokyo is the same birthday as in Berlin. A server log entry is an Instant, it must be stored in UTC, regardless of the server's local time. A calendar event is a ZonedDateTime, it has an explicit time zone, because a meeting at 10am in Berlin has a different UTC time during daylight saving than in winter. The Temporal API makes this distinction mandatory, not optional.
// Temporal API (distinct types for distinct concepts)
import { Temporal } from "@js-temporal/polyfill";
// PlainDate: a date without time or timezone (e.g., a birthday)
const birthday = Temporal.PlainDate.from("1990-03-15");
console.log(birthday.year); // 1990
console.log(birthday.month); // 3, 1-based, unlike Date!
console.log(birthday.day); // 15
// Instant: exact UTC point in time (e.g., log timestamps)
const logEntry = Temporal.Now.instant();
console.log(logEntry.epochSeconds); // Unix timestamp
console.log(logEntry.toString()); // "2026-05-10T14:30:00Z"
// ZonedDateTime: date, time AND explicit timezone (e.g., calendar events)
const meeting = Temporal.ZonedDateTime.from({
year: 2026, month: 6, day: 15,
hour: 10, minute: 0,
timeZone: "Europe/Berlin"
});
console.log(meeting.toString());
// "2026-06-15T10:00:00+02:00[Europe/Berlin]"
// Convert to another timezone (same instant, different wall clock)
const meetingInTokyo = meeting.withTimeZone("Asia/Tokyo");
console.log(meetingInTokyo.hour); // 17 (UTC+9, CEST is UTC+2)
3. PlainDate and PlainTime: Date Without a Time Zone
Temporal.PlainDate represents a date without a time and without a time zone. That sounds simple, but it eliminates a significant source of bugs: when you only want to store the date of an event with Date, you still end up creating a UTC timestamp with an implicit time (usually midnight UTC). That leads to the same date being displayed as the previous day, depending on the viewer's time zone. Temporal's PlainDate, by contrast, explicitly stores only year, month and day, with no time or time zone information at all.
Equally important: in the Temporal API, all objects are immutable. birthday.add({ years: 1 }) returns a new PlainDate object, the original object stays unchanged. That eliminates an entire class of bugs where date objects are accidentally mutated. PlainTime works the same way for times without a date and time zone. PlainDateTime combines date and time, but is likewise time zone-free, useful for local schedules or recipe timings, where the time zone plays no role.
4. Instant: The Exact Point in Time in UTC
Temporal.Instant conceptually corresponds to what Date was actually meant to represent: an exact point on the universal timeline, expressed as nanoseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). The difference: Instant is immutable, has no notion of a time zone and has no methods for reading local date properties, that is intentional. An Instant has only one value: an exact point in time. Everything else, hour, day, month, is an interpretation of that point in time within a particular time zone, and therefore the job of ZonedDateTime.
The resolution of Instant is nanoseconds, not milliseconds like Date. That matters for high-performance applications, Temporal.Now.instant() returns a nanosecond-accurate timestamp, provided the platform supports it. For database timestamps, API payloads and log entries, Instant is the correct type: instant.toString() always outputs an ISO 8601 string in UTC, regardless of the executing device's system time zone, a fundamental difference from new Date().toISOString(), which also outputs UTC, but is based on a mutable object.
// Instant: precise UTC timestamps with nanosecond resolution
import { Temporal } from "@js-temporal/polyfill";
// Current instant (nanosecond precision)
const now = Temporal.Now.instant();
console.log(now.epochNanoseconds); // BigInt: nanoseconds since Unix epoch
console.log(now.epochMilliseconds); // compatible with Date.now()
// Parse from ISO string (always UTC)
const ts = Temporal.Instant.from("2026-05-10T14:30:00.123456789Z");
console.log(ts.epochNanoseconds);
// Measuring durations precisely
const start = Temporal.Now.instant();
// ... do work ...
const end = Temporal.Now.instant();
const elapsed = end.since(start);
console.log(`Elapsed: ${elapsed.total("milliseconds")}ms`);
// Convert Instant to ZonedDateTime for human-readable output
const berlin = ts.toZonedDateTimeISO("Europe/Berlin");
console.log(berlin.hour); // 16 (UTC+2 in summer)
console.log(berlin.day); // 10
// Sorting: Instant supports compare()
const timestamps = [ts2, ts1, ts3].sort(Temporal.Instant.compare);
5. ZonedDateTime: Time Zone Explicit and Immutable
Temporal.ZonedDateTime is the most powerful and complete type in the Temporal API. It represents a point in time with an explicit time zone and all date properties. Unlike Instant, ZonedDateTime knows the time zone and correctly computes daylight saving transitions, transition periods and historical time zone adjustments. If you create a ZonedDateTime for October 27, 2024 at 2:30am in Europe/Berlin, right at the daylight saving clock change, the Temporal API handles this ambiguity explicitly and can be configured to pick either the summer-time or winter-time variant.
For calendar applications, booking systems and international scheduling, ZonedDateTime is indispensable. A meeting at 10am in Berlin stays at 10am even when a daylight saving transition falls in between, the Temporal API automatically computes the correct UTC time. The opposite, a fixed UTC point in time, would shift the meeting to 11am, which is often wrong. The Temporal API makes this distinction an explicit design decision the developer has to make.
6. Date Arithmetic: Duration and Differences
Date arithmetic is a source of errors with Date: months have different lengths, there are leap years, daylight saving transitions with 23 or 25 hours. The Temporal API abstracts all of that away with the Temporal.Duration type. A duration can contain years, months, weeks, days, hours, minutes, seconds and nanoseconds, and they are applied correctly to a concrete date value during arithmetic. date.add({ months: 1 }) on a January 31 date returns February 28, because February 31 does not exist. The configurable overflow behavior lets you choose the last day of the month instead.
Differences between date values are equally precise: date1.until(date2, { largestUnit: "months" }) computes the difference in whole months and remaining days, not in raw milliseconds that the developer would then have to convert into months manually. For age calculations, contract terms and payment deadlines, that is the correct approach. The Temporal API also supports non-Gregorian calendars such as the Islamic, Hebrew and Japanese calendars, all through the same API, but with calendar-specific correct arithmetic.
// Duration and date arithmetic with Temporal API
import { Temporal } from "@js-temporal/polyfill";
const start = Temporal.PlainDate.from("2026-01-31");
// Adding months: handles end-of-month correctly
const nextMonth = start.add({ months: 1 });
console.log(nextMonth.toString()); // "2026-02-28" (not Feb 31)
// Configurable overflow behavior
const constrained = start.add({ months: 1 }, { overflow: "constrain" }); // Feb 28
const rejected = start.add({ months: 1 }, { overflow: "reject" }); // throws
// Calculate age in years, months, days
const birthDate = Temporal.PlainDate.from("1990-03-15");
const today = Temporal.Now.plainDateISO();
const age = birthDate.until(today, { largestUnit: "years" });
console.log(`Age: ${age.years} years, ${age.months} months, ${age.days} days`);
// Contract duration (from signing to expiry)
const signed = Temporal.PlainDate.from("2026-02-15");
const expiry = signed.add({ years: 2, months: 3 });
const remaining = Temporal.Now.plainDateISO().until(expiry, { largestUnit: "days" });
console.log(`Contract expires in ${remaining.days} days`);
// DST-aware duration: 2 hours after a time, across DST boundary
const beforeDST = Temporal.ZonedDateTime.from("2026-10-25T01:00:00[Europe/Berlin]");
const afterDST = beforeDST.add({ hours: 2 });
// Correctly accounts for the extra hour during fall-back
7. Temporal vs. Date: The Direct Comparison
The difference between Date and the Temporal API is not just syntactic, it is conceptual. Date is a single, mutable type that crams every time concept into one inadequate interface. The Temporal API splits these concepts into specialized, immutable types that express exactly what is meant.
| Aspect | Date (old) | Temporal API (new) | Meaning |
|---|---|---|---|
| Mutability | Mutable (setMonth etc.) | Always immutable | No accidental changes |
| Month indexing | 0-based (Jan = 0) | 1-based (Jan = 1) | No off-by-one bugs |
| Time zones | UTC + system zone only | Every IANA time zone | Correct DST handling |
| Resolution | Milliseconds | Nanoseconds | Precise performance measurement |
| Date arithmetic | Manual ms calculation | Duration type, add/until | Leap year/DST automatic |
A particularly critical example: new Date("2026-05-10") parses the date as UTC midnight. If you display this date to a user in UTC-5, it shows up as May 9, a day too early. The Temporal API solves this with Temporal.PlainDate.from("2026-05-10"), a date with no time zone, that is always shown as May 10, no matter where the user is. This bug has occurred at least once in every large web application, and it regularly costs teams debugging time.
8. Formatting and Internationalization with Intl
The Temporal API is tightly coupled with the Intl API, JavaScript's built-in internationalization standard. Instead of implementing its own formatting logic, Temporal delegates output to Intl.DateTimeFormat. That means the same Temporal API base delivers correct date formats for every language and region: date.toLocaleString("de-DE", { dateStyle: "long" }) for "10. Mai 2026" in German, date.toLocaleString("en-US") for "May 10, 2026" in English.
The combination with non-Gregorian calendars is particularly powerful: Temporal.PlainDate.from({ calendar: "islamic", year: 1447, month: 11, day: 1 }) creates a date in the Islamic calendar that can be formatted and used for arithmetic with the same methods. For international applications that need to support multiple calendars, the Temporal API is the only clean path in native JavaScript, without external libraries weighing in at hundreds of kilobytes.
9. Migrating from Date to Temporal
The Temporal API is not yet an official standard, but it has been in TC39 Stage 3 for years and is polyfillable for many current browser versions. The official polyfill @js-temporal/polyfill implements the complete API and is available for Node.js and browsers. For new projects, using the polyfill right away is recommended; for existing projects, there is a step-by-step migration strategy.
The first step in migrating: identify every place where new Date() or Date.now() is used. Then decide which Temporal type correctly models the concept, PlainDate for pure dates, Instant for timestamps, ZonedDateTime for locally interpreted times. At the boundary to existing infrastructure (database, external APIs), conversion helps: Temporal.Instant.fromEpochMilliseconds(date.getTime()) converts an old Date object into an Instant. In the other direction: new Date(instant.epochMilliseconds).
// Migration helpers: bridging Date and Temporal API
import { Temporal } from "@js-temporal/polyfill";
// Date -> Temporal: convert legacy timestamps
function fromLegacyDate(date, timeZone = "UTC") {
return Temporal.Instant
.fromEpochMilliseconds(date.getTime())
.toZonedDateTimeISO(timeZone);
}
// Temporal -> Date: interop with APIs expecting Date
function toLegacyDate(temporalValue) {
if (temporalValue instanceof Temporal.Instant) {
return new Date(temporalValue.epochMilliseconds);
}
if (temporalValue instanceof Temporal.ZonedDateTime) {
return new Date(temporalValue.toInstant().epochMilliseconds);
}
throw new TypeError("Expected Instant or ZonedDateTime");
}
// Practical: parse API response date strings safely
function parseApiDate(isoString) {
// API sends "2026-05-10", store as PlainDate, never as Date
return Temporal.PlainDate.from(isoString);
}
// Practical: format for display in user's local timezone
function formatForUser(instant, locale, timeZone) {
const zdt = instant.toZonedDateTimeISO(timeZone);
return zdt.toLocaleString(locale, {
dateStyle: "long",
timeStyle: "short"
});
}
const ts = Temporal.Now.instant();
console.log(formatForUser(ts, "de-DE", "Europe/Berlin"));
// e.g., "10. Mai 2026, 16:30 Uhr"
10. Summary
The Temporal API is the long-awaited replacement for the flawed JavaScript Date object. With specialized, immutable types, PlainDate, PlainTime, PlainDateTime, Instant and ZonedDateTime, it solves every known problem: 0-based months, mutability, missing time zone support and imprecise date arithmetic. Every type models exactly the concept the developer means, no more implicit UTC midnight for pure dates, no missing DST corrections for time zone operations.
With the official polyfill @js-temporal/polyfill, the Temporal API can be used in any project today. Migrating existing Date usage can happen incrementally: new parts of the application use Temporal right away; conversion functions bridge the gap to older parts and external infrastructure. Anyone who starts using the Temporal API today writes code that keeps running once native support lands in every browser without a polyfill, the API itself stays stable, only the polyfill import goes away.
Mironsoft
JavaScript modernization, API migration and international web applications
Date bugs in your production system? We can help.
Time zone errors, off-by-one bugs with months and DST problems in existing applications, we migrate Date-based code to the Temporal API and eliminate the cause, not just the symptoms.
Error analysis
Identifying time zone errors, DST bugs and date arithmetic problems in existing applications
Temporal migration
Step-by-step migration from Date objects to the Temporal API with full regression testing
Internationalization
Multilingual date formatting and calendar support with Temporal + Intl
JavaScript Temporal API: The Essentials at a Glance
Type overview
PlainDate/Time: no time zone. Instant: exact UTC point. ZonedDateTime: complete with IANA time zone. Duration: for arithmetic. All immutable.
Critical improvements
1-based months. No UTC-midnight bug with PlainDate. DST-correct arithmetic. Nanosecond resolution. Non-Gregorian calendars natively.
Getting started today
npm install @js-temporal/polyfill, complete API, stable implementation. With native browser support, just remove the import.
Migration
Instant.fromEpochMilliseconds(date.getTime()) for the transition. new Date(instant.epochMilliseconds) back. Migrate module by module, incrementally.