temporal-gregorian
v0.2.0
Published
Node Temporal compatibility layer for libraries supporting Node 18-26: native Temporal on Node 26+, polyfilled before it, CJS + ESM, with implementation-agnostic type and runtime guards.
Maintainers
Readme
temporal-gregorian
A Node
Temporalcompatibility layer for libraries supporting Node 18–26 — nativeTemporalon Node 26+, polyfilled before it, with CJS and ESM support and implementation-agnostic type and runtime guards.
Status: early release (v0.x). API mirrors TC39 Temporal.
The problem this solves
Node 26 ships native Temporal. Node 24 LTS does not, and it is supported until
April 2028. So for the next couple of years a published library can receive
Temporal values that were built by:
- native Node 26
Temporal, temporal-polyfill,@js-temporal/polyfill,- or a second bundled copy of any of those.
Those values are spec-identical, but they are not interchangeable:
// A dependency built this with its own polyfill copy.
const zdt = fromDependency; // Temporal.ZonedDateTime
Temporal.ZonedDateTime.compare(zdt, mine);
// TypeError: Missing timeZone
zdt instanceof Temporal.ZonedDateTime;
// false — even though it is a perfectly valid ZonedDateTimeTypes have the same split. Importing your public signatures from one implementation's package means a native Node 26 value may not satisfy them — the conflict Fedify hit on TypeScript 6.
This package is the compatibility layer for that gap.
What it gives you
- One import, any Node 18–26. Native
Temporalwhen the runtime has it,temporal-polyfillwhen it does not. No code change on upgrade. - CJS and ESM both work, down to Node 18.
temporal-polyfillpublishes norequirecondition, sorequire()-ing it directly throwsERR_REQUIRE_ESMon Node 18 and Node 20 before 20.19. This package bundles it into its own.cjsbuild sorequire("temporal-gregorian")just works. - Implementation-agnostic public types. Types come from
temporal-spec, which is derived from TypeScript's ownesnext.intl.d.ts— the same declarations the built-in lib uses for nativeTemporal. A native value, a polyfill value, and a value from this package all satisfy the same signature. normalizeTemporal()— rebuild a foreignTemporalvalue with the active implementation, losslessly, so it is safe to use. See below.reflectutilities —getTemporalType(value),isTemporal(value),TEMPORAL_CTORS— runtime introspection that never usesinstanceof.comparefree functions — tree-shakeable forms of the static comparators.
Install
npm add temporal-gregorian
# or: pnpm add temporal-gregorian / yarn add temporal-gregorianimport { PlainDate, Now } from "temporal-gregorian";
const today = Now.plainDateISO();
const due = today.add({ days: 30 });Crossing implementations safely
import { normalizeTemporal, isNormalized, getTemporalType } from "temporal-gregorian";
export function schedule(when: unknown) {
// Works on values from ANY Temporal implementation — no instanceof.
if (getTemporalType(when) !== "ZonedDateTime") {
throw new TypeError("schedule() needs a Temporal.ZonedDateTime");
}
// Rebuild it with the active implementation. Returns it unchanged when it
// already belongs to the active one, so the common path costs nothing.
const zdt = normalizeTemporal(when);
return zdt.add({ hours: 1 }); // safe
}normalizeTemporal is lossless. Exact time is carried by epochNanoseconds (a
BigInt primitive, so it crosses implementations unchanged); everything else is
rebuilt from the value's canonical ISO string, which keeps full nanosecond
precision plus the calendar and time-zone annotations. ZonedDateTime is rebuilt
from (epochNanoseconds, timeZoneId, calendarId), so a tzdata difference between
the two implementations cannot shift the instant.
It throws TypeError for anything that is not a Temporal value, and
RangeError for calendars outside this package's ISO8601/Gregorian scope.
Per-type helpers are available when you want the stricter check:
normalizeInstant, normalizePlainDate, normalizePlainTime,
normalizePlainDateTime, normalizePlainYearMonth, normalizePlainMonthDay,
normalizeZonedDateTime, normalizeDuration. Each throws TypeError if handed
a different Temporal type.
isNormalized(value) tells you whether a value already belongs to the active
implementation, without throwing on non-Temporal input.
How it compares
| | temporal-gregorian | temporal-polyfill | @js-temporal/polyfill | native only |
| --- | --- | --- | --- | --- |
| Uses native Temporal on Node 26+ | yes | yes | no | yes |
| Works on Node 18–24 | yes | yes | yes | no |
| require() works on Node 18 / 20.0–20.18 | yes (bundled .cjs) | no (ERR_REQUIRE_ESM) | yes | n/a |
| Public types independent of any one implementation | yes (temporal-spec) | yes (temporal-spec) | no (own classes) | yes |
| Rebuild a foreign value (normalizeTemporal) | yes | no | no | no |
| Type/tag introspection without instanceof | yes | no | no | no |
| Calendars | ISO8601 + Gregorian | ISO + Gregorian (/full adds more) | all | all |
| Min+gzip on non-native Node | ~19.7 KB (ESM) / ~21.4 KB (CJS) | ~19.7 KB | ~46.9 KB | 0 |
Measured with esbuild --bundle --minify + gzip -9 on 2026-08-19.
Use temporal-polyfill directly if you are an application on Node 20.19+ and
only need Temporal itself — it is the implementation underneath this package
and you do not need a layer.
Use temporal-gregorian if you publish a library that must run on Node
18–26, must be require()-able, and may receive Temporal values built by
someone else's implementation.
Migrating
See Migrating a Node library from polyfilled Temporal to Node 26 native
Temporal — a step-by-step guide that keeps
Node 24, CJS, and TypeScript 6/7 working. A runnable demo lives in
examples/mixed-runtime/.
Compatibility, tested
CI runs on every push:
| Axis | Covered |
| --- | --- |
| Node | 18, 20, 22, 24, 26 |
| Module format | ESM and CJS, every published subpath |
| Runtime | native Temporal and polyfilled Temporal |
| TypeScript | 5.9, 6.0, 7.0 |
| Module resolution | NodeNext and Bundler |
| Interop | values from a different implementation crossing the API boundary |
npm run test:matrix runs the runtime half locally; npm run check:types-matrix
runs the TypeScript half. Both install the packed tarball into a clean directory,
so they test what consumers actually receive.
On Node 18–24 the "native" runtime is simulated by installing globalThis.Temporal
before the package loads, which is exactly what Node 26 does.
Scope
ISO8601/Gregorian calendars only. A value on another calendar is rejected with a
RangeError rather than silently mishandled.
Recurrence, intervals, relative-time, business-day helpers and duration
formatting are out of scope — they are already well served by
rrule-temporal, temporal-interval, Intl.RelativeTimeFormat and
Intl.DurationFormat. This package stays a compatibility layer.
Requirements
Node 18 or newer. Timezone data comes from the host ICU, same as native
Temporal; small-icu Node builds may lack full tzdata, so install the full-icu
package if your environment does not guarantee it.
Ecosystem
temporal-sql— Postgres ⇄ Temporal codecs forpg,postgres.js, Drizzle & Prisma, built on this package'sreflectprimitives. Correctinterval↔Durationand microsecond-precision safety, no JSDate.
License
MIT
