Convert a Legacy Date to a Temporal.Instant and Back
To move between a legacy Date and Temporal, go through the epoch-millisecond bridge: date.toTemporalInstant() (or Temporal.Instant.fromEpochMilliseconds(date.getTime())) converts forward, and new Date(instant.epochMilliseconds) converts back. This page is part of Working with ZonedDateTime Objects.
Why this scenario is tricky
The Date-to-Instant bridge is deceptively simple in the happy path and subtly lossy at the edges, which is what makes it worth getting right. Both a legacy Date and a Temporal.Instant are, at heart, a count from the Unix epoch, so converting between them is just moving that count across the boundary. The catch is precision: Date counts in milliseconds, while Instant counts in nanoseconds. Going from Date to Instant is lossless because you are widening precision, but going from an Instant that carries sub-millisecond digits back to a Date truncates them silently, and that truncation can reorder events that were distinct at nanosecond resolution.
This matters most during a migration, when a codebase has both types coexisting and values cross the boundary repeatedly. Each crossing back to Date is a potential precision cliff, so the discipline is to keep values as Instant (or higher Temporal types) for as long as possible and convert to Date only at the last interop point that genuinely requires one — a legacy API, a library that only speaks Date. Understanding that the bridge is a one-way precision ratchet is the key to using it safely.
A JavaScript Date is, internally, a single number: milliseconds since the Unix epoch in UTC. A Temporal.Instant is the same idea at higher resolution — nanoseconds since the epoch. Because both are pure absolute-time values with no attached time zone, the conversion between them is lossless in both directions for the millisecond range, provided you bridge on the epoch number rather than on a formatted string.
The trap is that developers reach for the string path — new Date(instant.toString()) or Temporal.Instant.from(date.toISOString()) — which works but is slower, allocates, and reintroduces parsing edge cases the numeric path avoids entirely. The other trap is resolution: a Date only holds milliseconds, so converting a nanosecond-precise Instant back to a Date truncates sub-millisecond digits. That is expected and fine for most apps, but it means instant → Date → instant is not always an identity round-trip.
A subtle point: an Instant is timezone-free. If you need wall-clock fields (year, month, hour) you must attach a zone with instant.toZonedDateTimeISO(timeZone) first — see the patterns in Compare ZonedDateTime Across Different Timezones and the broader introduction in Getting Started with the Temporal API.
The diagram shows the epoch number as the shared bridge between the two types.
API reference
| Direction | Call | Notes |
|---|---|---|
| Date → Instant | date.toTemporalInstant() |
Polyfill adds this to Date.prototype; lossless (ms → ns) |
| Date → Instant | Temporal.Instant.fromEpochMilliseconds(date.getTime()) |
Explicit, no prototype dependency |
| Instant → Date | new Date(instant.epochMilliseconds) |
Truncates sub-ms precision |
| Instant → wall clock | instant.toZonedDateTimeISO(timeZone) |
Required before reading local fields |
| Instant → ms / ns | instant.epochMilliseconds / instant.epochNanoseconds |
ms is number, ns is bigint |
Minimal working solution
The two directions are one line each. Temporal.Instant.fromEpochMilliseconds(date.getTime()) takes the millisecond count out of a Date and builds an Instant from it, and new Date(instant.epochMilliseconds) reads the millisecond count back out of an Instant to build a Date. Both pivot on the shared epoch-millisecond representation, which is why they are trivial: you are handing the same number across the fence. The Instant you get carries the same moment as the Date, now in a type that supports zone-aware projection and calendar arithmetic once you attach a zone.
Note what is not present in either value: a time zone. Both a Date and an Instant are pure moments with no civil interpretation, so the bridge preserves the instant exactly but tells you nothing about wall-clock time. To get a human-readable local time you convert the Instant to a ZonedDateTime with toZonedDateTimeISO(zone), which is a separate, deliberate step. Keeping the instant conversion and the zoning as distinct operations avoids the classic confusion of thinking a Date "has" a zone.
The shortest correct round-trip uses the numeric epoch bridge in both directions.
import { Temporal } from '@js-temporal/polyfill';
const legacy = new Date('2026-06-19T12:00:00.250Z');
// Date → Instant: pass epoch milliseconds straight into Temporal
const instant = Temporal.Instant.fromEpochMilliseconds(legacy.getTime());
console.log(instant.toString()); // '2026-06-19T12:00:00.25Z'
// Instant → Date: epochMilliseconds is a plain number Date accepts
const roundTrip = new Date(instant.epochMilliseconds);
console.log(roundTrip.getTime() === legacy.getTime()); // true — lossless at ms
The polyfill helper: toTemporalInstant()
The @js-temporal/polyfill adds a toTemporalInstant() method directly to Date.prototype, so legacyDate.toTemporalInstant() gives you the Instant without threading through getTime(). It is the idiomatic bridge because it reads as a single intention-revealing call and cannot be accidentally passed the wrong number. Under the hood it does exactly the epoch-based conversion described above, so it carries the same millisecond precision and the same zonelessness — it is a convenience, not a different operation.
Prefer it in application code for readability, but understand the mechanics so you can reason about precision. Because it originates from a Date, the resulting Instant is at millisecond resolution regardless of the nanosecond capacity of the type, which is fine — you cannot manufacture precision the source never had. The value of knowing the underlying fromEpochMilliseconds/epochMilliseconds path is that it makes the reverse direction and the precision limit obvious, which the sugar method alone can hide.
The TC39 design adds a toTemporalInstant() method to Date.prototype. The @js-temporal/polyfill installs it for you, so once the polyfill is imported you can call it directly — it is exactly equivalent to the fromEpochMilliseconds(getTime()) form but reads more naturally.
import { Temporal } from '@js-temporal/polyfill';
const legacy = new Date('2026-06-19T12:00:00Z');
// Prototype method installed by the polyfill — equivalent to the explicit form
const a = legacy.toTemporalInstant();
const b = Temporal.Instant.fromEpochMilliseconds(legacy.getTime());
console.log(a.equals(b)); // true — same epoch instant
If you support environments where the prototype patch might be stripped (aggressive tree-shaking, frozen prototypes), prefer the explicit fromEpochMilliseconds form so the conversion never depends on a mutated built-in.
Full production version
In a real migration you wrap the bridge in helpers that make the precision contract explicit and centralize the interop points. A toInstant(date) and a toLegacyDate(instant) pair, each documented as millisecond-precision, gives you a single place to add assertions — for example, rejecting or logging when an Instant about to be narrowed to a Date actually carries sub-millisecond digits, so a silent truncation cannot slip through unremarked. Concentrating the conversions also means the day you finish the migration, deleting the bridge is a one-file change.
The guiding rule is directionality: data should flow into Temporal early and flow back to Date only at the boundary that demands it. New code should accept and return Temporal types; the Date conversions live at the edges where you call a legacy library or hydrate an old persisted value. Structured this way, the precision cliff is crossed at most once per value, at a known place, rather than repeatedly and invisibly throughout the call graph. That containment is the difference between a migration that stays correct and one that accumulates rounding drift.
A robust interop layer validates the Date, converts forward, optionally projects into a zone for display, and converts back — never throwing on a valid value and never silently accepting an Invalid Date.
import { Temporal } from '@js-temporal/polyfill';
/** Convert a legacy Date to a Temporal.Instant, rejecting Invalid Date. */
export function dateToInstant(date: Date): Temporal.Instant {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new TypeError('dateToInstant requires a valid Date');
}
// getTime() is epoch ms; fromEpochMilliseconds is the lossless numeric bridge
return Temporal.Instant.fromEpochMilliseconds(date.getTime());
}
/** Convert a Temporal.Instant back to a Date (sub-millisecond precision truncates). */
export function instantToDate(instant: Temporal.Instant): Date {
// epochMilliseconds is a Number; Date holds only ms, so ns digits are dropped
return new Date(instant.epochMilliseconds);
}
/** Project a Date into a zone for wall-clock fields, then return a fresh Date. */
export function shiftToZone(date: Date, timeZone: string): Temporal.ZonedDateTime {
return dateToInstant(date).toZonedDateTimeISO(timeZone); // attaches the IANA zone
}
const now = new Date('2026-06-19T12:00:00.250Z');
const inst = dateToInstant(now);
console.log(instantToDate(inst).toISOString()); // '2026-06-19T12:00:00.250Z'
console.log(shiftToZone(now, 'Asia/Tokyo').toString());
// '2026-06-19T21:00:00.25+09:00[Asia/Tokyo]' — same instant, Tokyo wall clock
Verification snippet
These assertions confirm both conversion forms agree, the millisecond round-trip is exact, and nanosecond precision truncates exactly as documented.
import { Temporal } from '@js-temporal/polyfill';
const date = new Date('2026-06-19T12:00:00.250Z');
// Both forward conversions produce the same Instant.
const viaMethod = date.toTemporalInstant();
const viaStatic = Temporal.Instant.fromEpochMilliseconds(date.getTime());
console.assert(viaMethod.equals(viaStatic), 'method and static form agree');
// Round-trip Date → Instant → Date is exact at millisecond resolution.
const back = new Date(viaStatic.epochMilliseconds);
console.assert(back.getTime() === date.getTime(), 'ms round-trip is lossless');
// Going Instant(ns) → Date drops sub-millisecond digits.
const nanoInstant = Temporal.Instant.fromEpochNanoseconds(1_000_000_500n); // 1000ms + 500ns
const lossy = new Date(nanoInstant.epochMilliseconds);
console.assert(lossy.getTime() === 1000, 'Date truncates the 500ns remainder');
console.log('All interop assertions passed');
Common pitfalls
The first pitfall is round-tripping a high-precision Instant through Date and losing sub-millisecond digits, which can silently reorder events that were distinct. If you need nanosecond ordering, never route those values through Date; keep them as Instant. The second is assuming the bridge conveys a zone — it does not; both types are pure instants, so wall-clock interpretation requires a separate toZonedDateTimeISO(zone) step. Treating a Date as though it "is" local time is a category error the bridge does not fix.
A third mistake is using Date as an intermediate storage or transport format for Temporal values, which launders away not just precision but the zoned-versus-zoneless distinction and any calendar beyond ISO. Serialize Temporal values as their own canonical strings instead. Finally, in TypeScript, avoid letting the two types blur at call sites; give the bridge helpers explicit types so a Date cannot be passed where an Instant is expected, catching the confusion at compile time rather than as a runtime surprise.
-
Bridging through a string instead of the epoch number. Wrong:
Temporal.Instant.from(date.toISOString())— extra allocation and parsing surface. Right:Temporal.Instant.fromEpochMilliseconds(date.getTime())ordate.toTemporalInstant(). -
Expecting wall-clock fields on an
Instant. Wrong:instant.hour— anInstanthas no zone and no such property. Right:instant.toZonedDateTimeISO(timeZone).hour. -
Assuming
Instant → Date → Instantis an identity. Wrong: relying on nanosecond fidelity through aDate. Right: keep sub-millisecond values inTemporaland only drop toDateat the boundary where you genuinely need a legacyDate. -
Feeding
epochNanoseconds(abigint) to theDateconstructor. Wrong:new Date(instant.epochNanoseconds)—Dateexpects anumberof milliseconds and will coerce thebigintincorrectly or throw. Right: useinstant.epochMilliseconds.
Frequently Asked Questions
Is converting a Date to a Temporal.Instant lossless?
Yes, going forward. A Date stores epoch milliseconds, and fromEpochMilliseconds (or toTemporalInstant()) preserves that value exactly inside the higher-resolution Instant. The reverse direction can lose precision because a Date cannot hold the sub-millisecond nanoseconds an Instant may carry.
Where does Date.prototype.toTemporalInstant() come from?
It is part of the TC39 Temporal proposal and is installed on Date.prototype by @js-temporal/polyfill. It is equivalent to Temporal.Instant.fromEpochMilliseconds(date.getTime()); prefer the explicit static form if you cannot rely on the prototype being patched.
How do I get local date and time fields after converting a Date?
An Instant is timezone-free, so call instant.toZonedDateTimeISO(timeZone) to attach an IANA zone, then read .year, .month, .hour, and related properties on the resulting ZonedDateTime.
How do I convert a legacy Date to a Temporal.Instant and back?
Date to Instant: Temporal.Instant.fromEpochMilliseconds(date.getTime()), or the polyfill's date.toTemporalInstant(). Instant to Date: new Date(instant.epochMilliseconds). Both pivot on the shared epoch-millisecond count, so the instant is preserved exactly. Neither type carries a zone, so to get a local wall-clock reading convert the Instant with toZonedDateTimeISO(zone) as a separate step.
Does converting between Date and Instant lose precision?
Date to Instant is lossless because you widen from milliseconds to nanoseconds. Instant to Date truncates any sub-millisecond digits, because Date only stores milliseconds, and that truncation can reorder events that were distinct at nanosecond resolution. If you need that precision, keep values as Instant and avoid routing them through Date; convert to Date only at a final interop point that requires one.
When should I actually convert Temporal values back to Date?
Only at the boundary that genuinely needs a Date — a legacy API argument, or a library that does not accept Temporal types. New code should accept and return Temporal types, with Date conversions confined to the edges. This keeps each value crossing the millisecond precision cliff at most once, at a known place, instead of repeatedly and invisibly throughout the call graph, which is what keeps a migration free of rounding drift.