Moment.js to Temporal Migration Guide

To migrate from Moment.js to Temporal, replace mutable moment() objects with immutable Temporal.ZonedDateTime/Instant types, swap .add/.subtract/.diff/.format for their Temporal equivalents, and make every timezone explicit. Part of Migrating From Legacy Date Libraries.

Why this migration is tricky

Moment and Temporal disagree on two fundamentals, and that disagreement is where bugs hide. The first is mutability: moment.add() changes the object in place and returns it, while Temporal.ZonedDateTime.add() returns a brand-new object and leaves the original untouched. Any Moment code that called .add() for its side effect and ignored the return value will silently do nothing once ported to Temporal. The second is arithmetic semantics: Moment blurs calendar units and absolute units, whereas Temporal is deliberate β€” add({ days: 1 }) keeps the same wall-clock time (so it may be 23 or 25 real hours across a DST boundary), while add({ hours: 24 }) adds exactly 24 hours of absolute time. Porting mechanically without deciding which you meant produces off-by-one-hour errors that only appear twice a year.

Temporal also has no single "moment" type. A moment() carried date, time, zone, and offset all at once; in Temporal you pick Instant (an absolute point), ZonedDateTime (absolute + IANA zone), or PlainDateTime (wall-clock with no zone). Most Moment code maps to ZonedDateTime.

Mutable chains β†’ immutable valuesSemantics changeMutable chains β†’ immutable valuesmoment().add(1,'day')mutates in placezdt.add({ days: 1 })returns a new valueMoment mutates; Temporal is immutable β€” every reassignment must be captured.

Setup

Temporal is part of ES2026 and shipping natively in modern engines, but for broad support today, install the polyfill.

import { Temporal } from '@js-temporal/polyfill'; // drop this import once you target only native-Temporal runtimes

Install the polyfillAdd @js-temporal/polyfill and import TemporalInstall the polyfillnpm i@js-temporal/polyfillimport{ Temporal }useTemporal.*

The seven most common patterns, before and after

1. moment() β†’ current time

// BEFORE β€” local zone is implicit, easy to get wrong on a UTC server
const m = moment();

// AFTER β€” the IANA zone is explicit and travels with the object
const zdt = Temporal.Now.zonedDateTimeISO('America/New_York');

2. moment(string) β†’ parsing

// BEFORE β€” Moment accepts almost anything, hiding bad data
const m = moment('2024-03-10T08:30:00Z');

// AFTER β€” Instant.from is strict; bridge to a zone for wall-clock work
const instant = Temporal.Instant.from('2024-03-10T08:30:00Z'); // throws on malformed input
const zdt = instant.toZonedDateTimeISO('America/New_York');

3. .add() / .subtract() β†’ arithmetic

// BEFORE β€” mutates m in place; the original is gone
m.add(1, 'day').subtract(2, 'hours');

// AFTER β€” returns new values; chain reads left to right, original untouched
const result = zdt
  .add({ days: 1 })       // keeps wall-clock time across a DST change
  .subtract({ hours: 2 }); // subtracts absolute time

If a January 31 date is involved, choose an overflow policy explicitly:

// 'constrain' clamps Jan 31 + 1 month to Feb 28/29; 'reject' would throw instead
const nextMonth = zdt.add({ months: 1 }, { overflow: 'constrain' });

4. .format() β†’ ISO output

// BEFORE
const iso = m.format('YYYY-MM-DDTHH:mm:ssZ');
const dateOnly = m.format('YYYY-MM-DD');

// AFTER β€” toString() is ISO 8601 by spec; no token strings to mismatch
const iso = zdt.toString();              // e.g. 2024-03-11T06:30:00-04:00[America/New_York]
const dateOnly = zdt.toPlainDate().toString(); // 2024-03-11

5. .format(localized) β†’ localized display

// BEFORE β€” Moment's localized tokens
const pretty = m.format('LLL'); // "March 11, 2024 6:30 AM"

// AFTER β€” use Intl; cache the formatter, never build one per call
const fmt = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'long',
  timeStyle: 'short',
  timeZone: zdt.timeZoneId, // explicit zone prevents SSR host-zone drift
});
const pretty = fmt.format(new Date(zdt.epochMilliseconds));

6. .tz() β†’ converting zones

// BEFORE β€” moment-timezone
const tokyo = m.tz('Asia/Tokyo');

// AFTER β€” same absolute instant, re-projected into another zone
const tokyo = zdt.withTimeZone('Asia/Tokyo'); // wall-clock changes, instant is identical

7. .diff() β†’ durations

// BEFORE β€” returns a number in the requested unit
const days = laterMoment.diff(m, 'days');

// AFTER β€” returns a Duration; ask for the largest unit you want
const dur = laterZdt.since(zdt, { largestUnit: 'days' });
const days = dur.days; // integer day count; .total({ unit: 'day' }) for a fractional value

Bonus: .isBefore() β†’ comparison

// BEFORE
const earlier = m.isBefore(laterMoment);

// AFTER β€” static compare returns -1, 0, or 1
const earlier = Temporal.ZonedDateTime.compare(zdt, laterZdt) < 0;

Moment β†’ Temporal patternsThe recurring conversions in one viewMoment.jsTemporalmoment(s) β†’ parsem.add(1,'d') β†’ mutatem.format('LLL')m.tz('NY')m.diff(other,'days')m.startOf('day')m.isBefore(x)Temporal.*.from(s)zdt.add({days:1})Intl.DateTimeFormattoZonedDateTimeISO('NY')a.until(b,{largestUnit})zdt.startOfDay()Temporal.*.compare < 0

Full production version

A pragmatic migration proceeds pattern by pattern rather than all at once. Identify the handful of Moment idioms your codebase actually uses β€” "now", parse, format, add/subtract, diff, start-of-unit, zone conversion β€” and port each to its Temporal (and Intl) equivalent, making the zone explicit and choosing the right Temporal type at each site. Because Temporal values are immutable and its types are distinct, the ported code is not just equivalent but more correct: the zone bugs Moment hid become visible and fixable, and the mutation bugs become impossible. Do it incrementally behind a boundary so the app keeps working throughout.

The highest-value part of the migration is the zone-sensitive logic, where Moment's implicit local zone caused the most bugs. Converting those paths to ZonedDateTime with named zones, and doing DST-aware advancement with calendar units, removes the "worked on my laptop, wrong on the server" failures at their root. Keep the ISO/canonical string as the stored representation so data written during the migration is engine- and library-independent, and delete the Moment dependency (and eventually the polyfill) once the last idiom is ported. The end state is smaller bundle, explicit zones, immutable values, and no dependence on a maintenance-mode library.

A small typed helper isolates the migration and guards inputs so the strict parser does not crash callers that used to lean on Moment's leniency.

import { Temporal } from '@js-temporal/polyfill';

const fmtCache = new Map<string, Intl.DateTimeFormat>();

export function parseToZdt(iso: string, timeZone: string): Temporal.ZonedDateTime {
  if (typeof iso !== 'string' || iso.length === 0) {
    throw new TypeError('parseToZdt requires a non-empty ISO 8601 string');
  }
  // Instant.from rejects garbage that Moment would have silently coerced.
  return Temporal.Instant.from(iso).toZonedDateTimeISO(timeZone);
}

export function addDaysKeepingWallClock(
  zdt: Temporal.ZonedDateTime,
  days: number,
): Temporal.ZonedDateTime {
  // days are calendar units: the result keeps the same local clock time
  // even when the span crosses a DST transition (so it may be 23 or 25 hours).
  return zdt.add({ days });
}

export function formatLocalized(zdt: Temporal.ZonedDateTime, locale: string): string {
  const key = `${locale}|${zdt.timeZoneId}`;
  let f = fmtCache.get(key);
  if (!f) {
    f = new Intl.DateTimeFormat(locale, {
      dateStyle: 'long',
      timeStyle: 'short',
      timeZone: zdt.timeZoneId,
    });
    fmtCache.set(key, f); // reuse the formatter; construction is the expensive part
  }
  return f.format(new Date(zdt.epochMilliseconds));
}

Migrate at the boundaryWrap parsing/formatting, convert inward to TemporalMigrate at the boundarymoment callsitesfacade wrapsparse/formatinner logic→ Temporalremove moment

Verification

This block proves the two semantics that trip up mechanical ports: immutability and DST-aware day addition.

import { Temporal } from '@js-temporal/polyfill';

const base = Temporal.ZonedDateTime.from(
  '2024-03-09T12:00:00-05:00[America/New_York]',
);

// 1. Immutability: add() must not change the original.
const next = base.add({ days: 1 });
console.assert(base.hour === 12 && next.hour === 12, 'original unchanged, wall-clock preserved');

// 2. Adding 1 calendar day across spring-forward is 23 absolute hours.
const diffHours = next.since(base, { largestUnit: 'hours' }).hours;
console.assert(diffHours === 23, `expected 23 absolute hours across DST gap, got ${diffHours}`);

Migration assertionsBehaviour matches after each pattern is swappedAssertions that prove the edge caseparse equivalencesame instantadd days across DSTwall-clock heldformat outputsame stringcomparesame order

Common pitfalls

The first pitfall in a Moment-to-Temporal migration is mapping Moment's mutating API onto Temporal as if it were the same shape. Moment objects are mutable β€” m.add(1, 'day') changes m in place β€” while Temporal values are immutable and every operation returns a new value, so a literal translation that ignores the return value silently does nothing. Rewrite mutation chains as value-returning transformations, and the immutability becomes an asset (no aliasing bugs) rather than a trap. The second pitfall is Moment's implicit local zone: moment() uses the host zone silently, which is exactly the source of the UTC-server bugs, so the migration is the moment to make the zone explicit with ZonedDateTime and a named zone.

A third pitfall is assuming a one-to-one method mapping. Moment conflates instants, wall-clock times, and civil dates in one type, so a single Moment call may map to different Temporal types depending on what the value actually is β€” deciding Instant vs ZonedDateTime vs PlainDate per value is the real work of the migration, not a mechanical rename. A fourth is leaving the polyfill import in place as a permanent dependency without a plan; structure access so you can drop it once you target native-Temporal runtimes. A fifth is migrating formatting and arithmetic in one undifferentiated sweep β€” port arithmetic to Temporal and formatting to Intl as distinct steps, since they are separate concerns.

Moment→Temporal pitfallsCarrying mutable habits acrossWrongRightm.add() without reassignexpected mutationcapture the returned valueimmutabilitymoment(String) lax parseaccepts junkTemporal.*.from strictrejects junk

Frequently Asked Questions

What replaces a single Moment object in Temporal?

Usually Temporal.ZonedDateTime, which carries an absolute instant plus an explicit IANA timezone and calendar β€” the closest match to a zoned moment(). Use Temporal.Instant when you only need an absolute point with no zone, and Temporal.PlainDateTime for wall-clock values that have no timezone at all.

How do I convert a Moment to Temporal without losing precision?

Go through epoch milliseconds: Temporal.Instant.fromEpochMilliseconds(m.valueOf()).toZonedDateTimeISO(zone). Epoch milliseconds is absolute and lossless, so the round-trip never shifts the instant and never depends on the host timezone.

Why does my ported .add() seem to do nothing?

Because Temporal types are immutable. Moment's .add() mutated the object in place, but Temporal's returns a new object and leaves the original alone. Assign the result: zdt = zdt.add({ days: 1 }).

What is the biggest gotcha migrating from Moment.js to Temporal?

Moment objects are mutable β€” m.add(1, 'day') changes m in place β€” while Temporal values are immutable and every operation returns a new value. A literal translation that ignores the return value silently does nothing. Rewrite mutation chains as value-returning transformations. The other big one is Moment's implicit local zone; the migration is the moment to make the zone explicit with ZonedDateTime and a named zone.

Does each Moment method map to one Temporal method?

No. Moment conflates instants, wall-clock times, and civil dates in a single type, so one Moment call can map to different Temporal types depending on what the value actually is. Deciding Instant versus ZonedDateTime versus PlainDate per value is the real work of the migration, not a mechanical rename. Migrate formatting to Intl and arithmetic to Temporal as separate concerns, pattern by pattern.

How should I structure a Moment-to-Temporal migration?

Incrementally, pattern by pattern, behind a boundary so the app keeps working. Identify the Moment idioms your code actually uses β€” now, parse, format, add/subtract, diff, start-of-unit, zone conversion β€” and port each to its Temporal or Intl equivalent, making zones explicit and choosing the right Temporal type. Prioritize the zone-sensitive paths, keep canonical ISO strings as stored data, and drop the Moment dependency and polyfill once the last idiom is ported.