DEV Community

Cover image for Migrating a Node library from polyfilled `Temporal` to Node 26 native `Temporal`
Cna
Cna

Posted on

Migrating a Node library from polyfilled `Temporal` to Node 26 native `Temporal`

Without breaking Node 24, CJS, or TypeScript 6/7.

Node 26 enabled native Temporal by default on 2026-05-05. Node 24 LTS is
supported until April 2028. If you publish a library, both are your users at the
same time, and they will be for years.

This guide is for library authors. If you ship an application and control
your own Node version, your migration is one line — drop the polyfill when you
move to Node 26 — and you can stop reading.


The three things that actually break

Most migration advice says "the polyfill and native Temporal are
spec-compatible, so just swap them". That is true about behaviour and false about
identity. Three concrete failures:

1. instanceof stops working

foreignZdt instanceof Temporal.ZonedDateTime; // false
Enter fullscreen mode Exit fullscreen mode

The value is a perfectly valid ZonedDateTime. It just came from a different
implementation, so it has a different prototype chain. Any guard, any router, any
switch built on instanceof silently takes the wrong branch.

2. Some values are rejected outright

Passing a foreign value into a Temporal API falls back to reading it as a
property bag. For most types that quietly works. For ZonedDateTime it does not:

Temporal.ZonedDateTime.compare(foreignZdt, mine);
// TypeError: Missing timeZone
Enter fullscreen mode Exit fullscreen mode

@js-temporal/polyfill exposes timeZoneId, not timeZone, so the property-bag
path finds nothing to read. This is a hard failure at runtime, in production, on
a mixed-version fleet — and it is exactly what
Fedify hit.

Borrowed methods fail too:

Temporal.PlainDate.prototype.add.call(foreignDate, { days: 1 });
// TypeError: Invalid calling context
Enter fullscreen mode Exit fullscreen mode

3. Your public types are tied to one implementation

import type { Temporal } from "@js-temporal/polyfill";
export function schedule(when: Temporal.ZonedDateTime): void;
Enter fullscreen mode Exit fullscreen mode

That signature describes that polyfill's classes. A caller on Node 26 holding a
native ZonedDateTime may not satisfy it, and on TypeScript 6 the two
declarations
conflict outright.

There is a fourth trap that only bites CJS users — covered in step 4.


Step 1 — Stop importing types from an implementation

Take your public types from a types-only package that describes the spec
rather than a class hierarchy.
temporal-spec is derived from
TypeScript's own esnext.intl.d.ts, so it is the same shape the built-in lib
uses for native Temporal.

- import type { Temporal } from "@js-temporal/polyfill";
+ import type { Temporal } from "temporal-spec";

  export function schedule(when: Temporal.ZonedDateTime): void;
Enter fullscreen mode Exit fullscreen mode

Now a native value, a temporal-polyfill value and a @js-temporal/polyfill
value all satisfy the signature.

If you use temporal-gregorian, re-export it from there instead so your
consumers need no extra dependency:

import type { Temporal } from "temporal-gregorian/types";
Enter fullscreen mode Exit fullscreen mode

Verify it. Write a fixture that pushes values both directions across the
line and type-check it under every TypeScript version you support:

declare const nativeZdt: Temporal.ZonedDateTime; // what Node 26 hands you
declare function takesNative(v: Temporal.ZonedDateTime): void;

takesNative(myLibrary.build());   // ours -> native-typed slot
myLibrary.schedule(nativeZdt);    // native -> ours
Enter fullscreen mode Exit fullscreen mode

Run it under moduleResolution: NodeNext and Bundler. They resolve
declarations differently and a package can pass one while failing the other.

Step 2 — Delete every instanceof check

Replace them with a check on Symbol.toStringTag, which every Temporal value
carries and every implementation sets identically:

- if (value instanceof Temporal.ZonedDateTime) { ... }
+ if (getTemporalType(value) === "ZonedDateTime") { ... }
Enter fullscreen mode Exit fullscreen mode

Rolling your own is ~6 lines:

export function getTemporalType(value) {
  if (value === null || typeof value !== "object") return undefined;
  const tag = value[Symbol.toStringTag];
  if (typeof tag !== "string" || !tag.startsWith("Temporal.")) return undefined;
  return tag.slice("Temporal.".length);
}
Enter fullscreen mode Exit fullscreen mode

Note the caveat: Symbol.toStringTag is an ordinary forgeable property. It is
the right tool for telling apart values you already trust, not for validating
untrusted input. Step 3 closes that gap.

Step 3 — Normalize values at your API boundary

Detecting a foreign value is not enough — you still have to use it. Rebuild it
with whichever implementation is active in your process:

import { normalizeTemporal, getTemporalType } from "temporal-gregorian";

export function schedule(when) {
  if (getTemporalType(when) !== "ZonedDateTime") {
    throw new TypeError("schedule() needs a Temporal.ZonedDateTime");
  }
  const zdt = normalizeTemporal(when); // returns `when` unchanged if already ours
  return zdt.add({ hours: 1 });        // safe
}
Enter fullscreen mode Exit fullscreen mode

Doing it by hand, the two rules that matter:

  • Exact time crosses as a BigInt, not a string. epochNanoseconds is a primitive, so it is implementation-independent and cannot lose precision.
  • Rebuild ZonedDateTime from (epochNanoseconds, timeZoneId, calendarId), not from its offset string. If the two implementations carry different tzdata versions, re-parsing an offset can move the instant or throw. Pinning the epoch value cannot.

Everything else round-trips losslessly through toString(), which carries full
nanosecond precision plus the calendar and time-zone annotations.

Normalize once, at the boundary — not on every internal call. Inside your
library the values are already yours.

Step 4 — Do not break CJS

This is the step most migrations miss.

temporal-polyfill publishes no require condition in its exports. So:

require("temporal-polyfill"); // Node 18, Node 20.0-20.18:
// Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
Enter fullscreen mode Exit fullscreen mode

It works on Node 20.19+, 22.12+, 24 and 26 (which support require(esm)), and
fails below that. If your library is dual-published and any consumer is on Node
18, a plain dependency on temporal-polyfill breaks them.

Three ways out, in order of preference:

  1. Bundle the polyfill into your CJS build only. ESM keeps it external so consumers share one copy; CJS inlines it so require() works everywhere. This is what temporal-gregorian does — see its tsup.config.ts.
  2. Raise your engines floor to >=20.19 and keep the dependency external. Simpler, but it is a breaking change for your Node 18 users.
  3. Stay on @js-temporal/polyfill, which ships a real CJS build — at roughly 2.4× the bundle size (~46.9 KB vs ~19.7 KB min+gzip) and with the type problem from step 1 still unsolved.

Bundling has one cost, and you should know it: a CJS consumer who also installs
the polyfill directly ends up with two copies, which is precisely the foreign-value
situation from step 3. That is why step 3 comes first.

Step 5 — Prove it with a matrix, not with a smoke test

Importing your package once on your laptop proves nothing here. The failures are
combinational. Test the packed tarball — not your source tree — across:

Axis Values
Node 18, 20, 22, 24, 26
Module format ESM, CJS
Runtime native Temporal, polyfilled Temporal
TypeScript 5.9, 6.0, 7.0
Module resolution NodeNext, Bundler
Interop a value from another implementation crossing your API

You do not need Node 26 runners to test the native path. Install
globalThis.Temporal before your package loads and any Node 18+ process behaves
like a native one:

// simulate-native.mjs — load with: node --import ./simulate-native.mjs app.mjs
import { Temporal } from "temporal-polyfill/implementation";
globalThis.Temporal = Temporal;
Enter fullscreen mode Exit fullscreen mode

In ESM this must live in its own module, imported first. Every import's body
runs before the importing module's own statements, so an inline assignment lands
after your package has already read the global.

Working implementations of both matrices:
scripts/compat-matrix.mjs and
scripts/check-type-resolution.mjs.

Step 6 — Let the runtime pick

Once the above is in place, the actual "migration" is nothing. Feature-detect at
load and pass through:

const Temporal = globalThis.Temporal ?? polyfillTemporal;
Enter fullscreen mode Exit fullscreen mode

temporal-polyfill already does this internally, and so does
temporal-gregorian. Your Node 26 users get native Temporal at zero overhead
the day they upgrade; your Node 24 users notice nothing; and neither can hand the
other a value your library chokes on.


See it fail, then see it work

git clone https://github.com/sina-heidariaan/temporal-gregorian
cd temporal-gregorian
npm ci && npm run build

npm run demo          # polyfilled runtime
npm run demo:native   # simulated Node 26 native runtime
Enter fullscreen mode Exit fullscreen mode

Source: examples/mixed-runtime/demo.mjs.

Checklist

  • [ ] Public types come from temporal-spec (or temporal-gregorian/types), not from a polyfill package.
  • [ ] Zero instanceof checks against Temporal classes.
  • [ ] Foreign values are normalized once, at the API boundary.
  • [ ] ZonedDateTime is rebuilt from epochNanoseconds, not from an offset string.
  • [ ] require() works on your lowest supported Node.
  • [ ] CI covers Node × format × runtime × TypeScript × module resolution.
  • [ ] The native path is tested, even without a Node 26 runner.

Found a case this guide misses, or a combination that still breaks? Please open an issue at temporal-gregorian/issues
— real-world interop reports are what this package is for.

Top comments (0)