How would you introduce Temporal into a large existing JavaScript codebase that uses the built-in Date everywhere, given that Temporal is still a proposal and ships unevenly?
answer
- convert at the edges, not everywhere
- lossless through the epoch
- sequence by blast radius
- fix storage shapes first
- the middle state is the risk
basics
~20 sAdopt at boundaries rather than by sweep: convert Date to Temporal on the way in and back on the way out, move the highest-risk zone and calendar logic first, and pull Temporal from the official polyfill until engine support is broad.
solid answer
~50 sI would treat it as a boundary migration, not a find-and-replace. Temporal is a Stage 3 proposal — Firefox ships it as of 2025, other engines are in progress — so production code uses `@js-temporal/polyfill`, and that bundle cost is a real input to the decision. Interop is cheap and lossless in both directions through the epoch: `Temporal.Instant.fromEpochMilliseconds(d.getTime())` in, `new Date(instant.epochMilliseconds)` out. So I would establish a rule that Temporal types are used inside the domain and `Date` only survives at edges that demand it, converting at those edges. Sequencing follows risk: the code doing zone-aware or calendar arithmetic — scheduling, billing periods, recurrence — moves first, since that is where `Date` actually costs money. Timestamps that are only ever compared or logged can stay for a long time. Two behaviours to warn the team about: Temporal's `valueOf` throws, so `a - b` and `a < b` break loudly, and `JSON.stringify` emits ISO strings that need an explicit `Temporal.X.from()` to reconstruct.
code
javascript · 9 linesimport { Temporal } from '@js-temporal/polyfill';
// Lossless interop at the boundary, in both directions
export const toInstant = (d) => Temporal.Instant.fromEpochMilliseconds(d.getTime());
export const toDate = (instant) => new Date(instant.epochMilliseconds);
const legacy = new Date('2026-06-01T17:00:00Z');
console.log(toInstant(legacy).toString()); // 2026-06-01T17:00:00Z
console.log(toDate(toInstant(legacy)).getTime() === legacy.getTime()); // truego deeper
Know that Temporal and Date convert through the epoch — fromEpochMilliseconds in, epochMilliseconds out — so the two can coexist while a codebase moves over gradually.
Describe a boundary-conversion strategy and name the friction points: valueOf throwing, immutable methods whose results must be used, and JSON reconstruction needing an explicit from() call.
Sequence the work by where Date bugs actually cost money — scheduling, billing periods, date-only fields — and settle the storage and wire formats before touching internals so the data is not migrated twice.
Own the cost-benefit case including polyfill weight and engine timelines, define the policy that stops an indefinite half-migrated state, and state what 'done' means — including when the polyfill comes back out.
## Frame the decision honestly Temporal is not free. As a Stage 3 TC39 proposal it is not yet universally available in shipped engines: Firefox enabled it by default in 2025, other engines are in progress, and until that spreads, production code depends on `@js-temporal/polyfill`, which is a non-trivial addition to a browser bundle. So the first thing a lead owes the team is a reason. "Modern API" is not one. The reason is that `Date` conflates an instant with a wall-clock reading, mutates in place, and has no notion of a time zone beyond the host's, and those properties generate a specific, recurring class of defects. If your product does no zone-aware or calendar arithmetic, the honest answer may be "not yet". ## Interop is the enabling fact A gradual migration is only viable because the two representations convert cheaply and losslessly through the epoch: ```js const d = new Date(); const instant = Temporal.Instant.fromEpochMilliseconds(d.getTime()); const back = new Date(instant.epochMilliseconds); ``` Nothing is lost in either direction at millisecond precision, which is all a `Date` has. That means Temporal can be adopted module by module without a flag day: a function can accept a `Date`, convert on entry, compute in Temporal, and convert back on return. Callers see no change. ## Sequence by risk, not by file count Rank the code by what a `Date` bug there actually costs. **Move first:** scheduling and recurrence, billing and subscription periods, anything doing "same time tomorrow" or "the first of next month", and anything that renders a time in a zone other than the server's. This is where the conflation bites, and where `ZonedDateTime` arithmetic and the explicit `disambiguation` option pay for themselves immediately. **Move next:** date-only values — birth dates, invoice dates, effective dates. Modelling these as `Temporal.PlainDate` removes the entire family of off-by-one-day bugs caused by a date-only value acquiring a midnight that then shifts across a zone boundary. **Move last, or never:** timestamps that are only compared, sorted, or logged. A `Date` used purely as an epoch number is not wrong, just unexpressive. Rewriting it buys little and costs review time. ## Fix the boundaries first Before converting internals, decide the storage and wire formats, because those outlive the code. Establish that exact times persist as `Z`-suffixed ISO strings or epoch values, date-only fields persist as `YYYY-MM-DD`, and future local events persist as a wall-clock value plus an IANA zone ID. Once the boundary shapes are right, the internal type can change under them without another migration. Doing it in the other order means migrating twice. Serialization is direct: every Temporal type has `toJSON()`, so `JSON.stringify` produces the ISO form automatically. The reverse is not automatic — a parsed object is a string until you call `Temporal.Instant.from(...)`, `Temporal.PlainDate.from(...)` and so on. Centralise that reconstruction in your deserialisation layer rather than scattering `from()` calls. ## Warn the team about three behaviours First, **`valueOf` throws.** Every Temporal type deliberately throws a `TypeError` from `valueOf()`, so `a - b`, `a < b` and `a > b` fail loudly instead of quietly comparing strings. This is a feature — it catches exactly the code that would have been wrong — but a team converting a module will hit it immediately and should know the replacements are `Temporal.X.compare(a, b)` and `a.until(b, { largestUnit })`. The good news is that these are runtime failures at the offending line, not silent drift. Second, **immutability changes call shape.** `d.add({ days: 1 })` returns a value and mutates nothing, so a mechanical translation of a mutating `setDate` call that discards the result runs fine and does nothing. Third, **types no longer interchange.** A function that previously took "a date" now takes a `PlainDate` or a `ZonedDateTime`, and mixing them is an error rather than an implicit conversion. That friction is the point, but it means signatures need deciding rather than defaulting. ## Keep it from stalling half-done The failure mode of every gradual migration is an indefinite middle state where both representations circulate and nobody knows which a given function expects. Guard against it: name the boundary modules where conversion is allowed and forbid it elsewhere in review, keep one utility that does the `Date` interop so the conversion sites are greppable, and set a policy that new code is Temporal-only from a chosen date. If your codebase already carries a third date library, fold that into the same effort rather than adding a fourth representation. Finally, decide up front what "done" means and whether it includes dropping the polyfill once engine support is broad, because that is the point where the bundle cost you accepted is repaid.
- Which parts of a codebase would you deliberately leave on Date?Code where a Date is used purely as an epoch number — comparisons, sorting, log lines, cache stamps — and any edge whose API demands a Date. Those carry no zone or calendar semantics, so converting buys expressiveness and nothing else. Spending review effort there is how a migration runs out of momentum before reaching the code that actually has bugs.
- What breaks first when a team mechanically converts a module from Date to Temporal?Arithmetic and comparison. Temporal's `valueOf()` throws a TypeError by design, so `a - b`, `a < b` and sort comparators using `<` fail immediately, and any mutating call translated to `add()` whose result is discarded now silently does nothing. The first is loud and easy; the second is the one to look for in review.
- Why decide the storage and wire formats before converting internal code?Because persisted shapes outlive the code and are far more expensive to change. Settle that exact times are ISO Z strings, date-only fields are YYYY-MM-DD, and future local events carry a wall-clock value plus an IANA zone ID; then the internal type can change under a stable boundary. Converting internals first means migrating the data twice.
- How do you justify shipping the polyfill's bundle cost?By pointing at the defect class it removes rather than at API modernity. If the product does zone-aware scheduling, recurring billing, or renders times in users' zones, the polyfill replaces bespoke or third-party date code and often nets out neutral. If it does none of those, the honest answer is to wait for engine support and adopt then.
saying these in an interview costs you the question
- Do a codebase-wide find-and-replace in one pull request
- Temporal and Date cannot interoperate, so it is all or nothing
- Adopt it everywhere because it is the modern API
- Existing comparison code keeps working after conversion
- JSON round-trips Temporal values back into Temporal objects automatically