Diego Betto's Blog
Photo by Aron Visuals on Unsplash

Diego Betto · September 15, 2026 · 7 min di lettura

Replacing Moment.js, date-fns, Luxon, and Day.js with Temporal

A practical migration cheatsheet: how to do formatting, parsing, arithmetic, diffing, comparison, and time zone conversion in Temporal, mapped directly to the Moment/date-fns/Luxon/dayjs calls you already know.

Condividi:XLinkedInFacebookWhatsApp

The previous article on Temporal covered why it exists and its core types. This one is the reference you actually reach for during a migration: the exact Temporal call for every common thing you’re doing today with Moment.js, date-fns, Luxon, or Day.js.

ℹ️ Setup

Where Temporal isn’t natively available yet, install the official polyfill: npm install @js-temporal/polyfill. Import it once as import {Temporal} from "@js-temporal/polyfill"; — every example below uses the same global Temporal object either way.

Formatting

// Moment
moment().format("YYYY-MM-DD"); // "2026-09-15"

// date-fns
format(new Date(), "yyyy-MM-dd"); // "2026-09-15"

// Luxon
DateTime.now().toFormat("yyyy-MM-dd"); // "2026-09-15"

// Temporal — ISO output is built in, no format string needed for this case
Temporal.Now.plainDateISO().toString(); // "2026-09-15"

For anything locale-aware (weekday names, “Sep 15, 2026” style output), Temporal defers to Intl.DateTimeFormat instead of inventing its own format-string language:

const date = Temporal.Now.plainDateISO();

new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(date);
// "September 15, 2026"

date.toLocaleString("en-US", { weekday: "long", month: "short", day: "numeric" });
// "Tuesday, Sep 15"

toLocaleString on any Temporal object forwards straight to Intl.DateTimeFormat — there’s no separate format-token syntax (yyyy, MMM, etc.) to learn or keep in sync with a library’s docs.

Parsing

// Moment — accepts near-anything, which is exactly the ambiguity problem
moment("2026-09-15T14:30:00");

// date-fns
parseISO("2026-09-15T14:30:00");

// Luxon
DateTime.fromISO("2026-09-15T14:30:00");

// Temporal — strict ISO 8601 / RFC 9557 only, throws on ambiguous input instead of guessing
Temporal.PlainDateTime.from("2026-09-15T14:30:00");

⚠ Temporal will not parse loose formats

Temporal.PlainDate.from("09/15/2026") throws a RangeError. This is deliberate — Temporal refuses to guess whether that’s month/day or day/month. If you’re ingesting non-ISO strings (a CSV export, a legacy API), parse them into an ISO string yourself first, the same way you’d validate any other untrusted input.

Getting “now”

// Moment
moment();

// date-fns
new Date();

// Luxon
DateTime.now();

// Temporal — pick the type that matches what you actually need
Temporal.Now.plainDateISO(); // just today's date
Temporal.Now.plainDateTimeISO(); // today's date + local time, no zone
Temporal.Now.zonedDateTimeISO(); // instant + local time zone — what you usually want
Temporal.Now.instant(); // raw UTC instant, no calendar/zone

This is the biggest mental shift coming from any Date-based library: instead of one object for every use case, you pick the Temporal type that matches the question you’re actually asking. Reaching for zonedDateTimeISO() by default covers most “what time is it right now, here” use cases.

Adding and subtracting

// Moment
moment().add(3, "days").subtract(2, "hours");

// date-fns
subHours(addDays(new Date(), 3), 2);

// Luxon
DateTime.now().plus({ days: 3 }).minus({ hours: 2 });

// dayjs
dayjs().add(3, "day").subtract(2, "hour");

// Temporal
Temporal.Now.zonedDateTimeISO().add({ days: 3 }).subtract({ hours: 2 });

Chaining works the same way you’re used to from Luxon/dayjs, and every intermediate value is a new immutable instance — nothing here can accidentally mutate a shared object the way moment().add(...) mutates in place.

Difference between two dates

// Moment
moment(end).diff(moment(start), "hours");

// date-fns
differenceInHours(end, start);

// Luxon
end.diff(start, "hours").hours;

// Temporal — until()/since() return a Duration, not a raw number
start.until(end, { largestUnit: "hours" }); // Temporal.Duration
start.until(end, { largestUnit: "hours" }).hours; // just the number, if that's all you need

until/since return a full Temporal.Duration (hours, minutes, seconds all populated as needed), not a single float you have to remember the unit of — you ask for the unit breakdown you want via largestUnit/smallestUnit instead of calling a differently-named function per unit like differenceInHours / differenceInDays / differenceInMinutes.

Comparison

// Moment
moment(a).isBefore(b);
moment(a).isAfter(b);
moment(a).isSame(b);

// date-fns
isBefore(a, b);
isAfter(a, b);
isEqual(a, b);

// Luxon
a < b; // Luxon DateTime supports valueOf-based comparison
a.equals(b);

// Temporal
Temporal.PlainDate.compare(a, b); // -1, 0, or 1 — sort-friendly
a.equals(b);

Temporal.PlainDate.compare (and the equivalent compare static on every Temporal type) returns the same -1/0/1 shape Array.prototype.sort expects, so sorting a list of Temporal values is dates.sort(Temporal.PlainDate.compare) with no comparator function to write by hand.

Time zone conversion

// Moment (requires moment-timezone, a separate package)
moment.tz(date, "America/New_York").format();

// date-fns (requires date-fns-tz, a separate package)
formatInTimeZone(date, "America/New_York", "yyyy-MM-dd HH:mm:ssXXX");

// Luxon — built in
dt.setZone("America/New_York");

// Temporal — built in, no extra package regardless of which library you're replacing
zonedDateTime.withTimeZone("America/New_York");

This is the one where the gap is biggest: Moment and date-fns need a separate time-zone package (moment-timezone, date-fns-tz) just to do zone conversion correctly, because neither ships IANA time zone data by default. Temporal has this built in — withTimeZone is a method on ZonedDateTime, nothing extra to install.

Cheatsheet

Task Moment date-fns Luxon Temporal
Now moment() new Date() DateTime.now() Temporal.Now.zonedDateTimeISO()
Parse ISO string moment(str) parseISO(str) DateTime.fromISO(str) Temporal.PlainDateTime.from(str)
Format .format("YYYY-MM-DD") format(d, "yyyy-MM-dd") .toFormat("yyyy-MM-dd") .toLocaleString(locale, opts)
Add duration .add(3, "days") addDays(d, 3) .plus({ days: 3 }) .add({ days: 3 })
Subtract duration .subtract(2, "hours") subHours(d, 2) .minus({ hours: 2 }) .subtract({ hours: 2 })
Diff .diff(other, "hours") differenceInHours(a, b) .diff(other, "hours") .until(other, { largestUnit: "hours" })
Compare .isBefore(other) isBefore(a, b) a < b Temporal.PlainDate.compare(a, b)
Change time zone .tz("America/New_York") (needs moment-timezone) formatInTimeZone(...) (needs date-fns-tz) .setZone("America/New_York") .withTimeZone("America/New_York")
Mutability Mutates in place Immutable Immutable Immutable

What doesn’t map 1:1

A few things worth knowing before you start swapping calls:

  • No format-token strings. There’s no Temporal equivalent of "YYYY-MM-DD HH:mm" — formatting goes through Intl.DateTimeFormat options ({ year: "numeric", month: "2-digit", ... }) instead. For a handful of ISO-shaped outputs, toString()/toJSON() already give you what a format string would.
  • No lenient parsing. Any code relying on Moment’s “just figure it out” parsing of ambiguous strings needs an explicit conversion step before it reaches Temporal — see the warning above.
  • Relative time (“3 days ago”) isn’t built into Temporal itself. Use Intl.RelativeTimeFormat with the numeric difference from until/since, the same way you’d already combine a diff function with a formatting layer in date-fns.

FAQ

❓ Can I migrate incrementally, file by file?

Yes — Temporal objects convert cleanly to/from native Date (Temporal.Instant.fromEpochMilliseconds(date.getTime()) and instant.epochMilliseconds), so a Temporal-based module and a Moment/date-fns-based one can coexist while you migrate piece by piece rather than all at once.

❓ Does this remove the need for date-fns-tz or moment-timezone specifically?

Yes — time zone conversion is one of Temporal’s built-in strengths, covered above. That’s usually the single biggest dependency-weight win from migrating, since IANA time zone data is what makes those add-on packages non-trivial in the first place.

❓ Is there a bundle-size reason to migrate, not just an API one?

Where Temporal is natively supported by your target runtimes, yes — it ships with the engine, so you drop the library’s bundle weight entirely. Until then, the polyfill has its own size cost, so the win is really about API correctness first and bundle size second, once native support lands.

Conclusion

The move from Moment/date-fns/Luxon/dayjs to Temporal is mostly a mechanical one, once you know the mapping: pick the Temporal type matching what you’re representing, swap format-string calls for Intl.DateTimeFormat/toLocaleString, and drop the separate time-zone package since withTimeZone is built in. The cheatsheet above covers the calls you’ll hit in the first pass through any real codebase.

Need this kind of migration done for you?

I work as a JavaScript/TypeScript developer and consultant through PAPION, based in Udine, Italy — for clients in Udine and Friuli, and remotely for teams across Italy and abroad. If you’re planning a library migration, or need React, Node.js, or TypeScript work more broadly, get in touch.

References

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.