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.
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
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;
Full production version
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));
}
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}`);
Common pitfalls
- Ignoring the return value.
zdt.add({ days: 1 });on its own does nothing — Temporal is immutable.- Wrong:
zdt.add({ days: 1 });Right:zdt = zdt.add({ days: 1 });
- Wrong:
- Confusing
dayswithhours. Across DST these differ by an hour.- Wrong:
zdt.add({ hours: 24 })to mean "tomorrow" Right:zdt.add({ days: 1 })
- Wrong:
- Rebuilding formatters per call. Constructing
Intl.DateTimeFormatis costly.- Wrong:
new Intl.DateTimeFormat(l, o).format(d)in a loop Right: cache bylocale+timeZoneand reuse.
- Wrong:
- Storing a numeric offset. Offsets break under DST and rule changes.
- Wrong: persist
-05:00Right: persist the IANA idAmerica/New_York.
- Wrong: persist
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 }).