Calendar Systems and Era Handling with the Temporal API

Render and compute dates correctly across Gregorian, Japanese imperial, Hebrew, and Islamic calendars โ€” including era boundaries โ€” using calendar-aware Temporal types instead of the proleptic-Gregorian-only legacy Date. Part of Modern Date Logic with the Temporal API.

The problem: legacy Date only knows one calendar

A user in Tokyo sees "ไปคๅ’Œ6ๅนด" (Reiwa 6) on a government form; a user in Riyadh expects Hijri dates; an archivist needs 44 BCE to round-trip without an off-by-one. The legacy Date object cannot serve any of them. Date implicitly assumes the proleptic Gregorian calendar โ€” it extends Gregorian rules backward past the calendar's historical 1582 adoption โ€” and strips era metadata entirely. There is no era property, no Japanese imperial era support, and no concept of a Hebrew leap month (Adar I + Adar II). Teams end up hand-rolling calendar conversion tables, which silently drift when an emperor abdicates (Heisei โ†’ Reiwa, May 1 2019) or when a lunar month's length is disputed between Islamic calendar variants. The result is wrong displayed dates, broken sorting near era boundaries, and locale tickets that never close.

The Temporal API fixes this by making calendar a first-class property on its plain types. If you are new to Temporal, start with getting started with the Temporal API for installation and the core type model, then return here for calendar-specific logic.

The diagram below shows the single instant of May 1, 2024 rendered in four calendar systems, plus the Heisei โ†’ Reiwa era boundary that makes Japanese-era math non-linear.

One instant across four calendars and the Heisei-to-Reiwa era boundary The same date, May 1 2024, shown in Gregorian, Japanese, Islamic and Hebrew calendars on the left, and a timeline on the right marking the abdication on April 30 2019 where Heisei 31 becomes Reiwa 1. One instant: 2024-05-01 Gregorian (gregory) 2024 CE ยท May 1 Japanese (japanese) Reiwa 6 ยท era=reiwa Islamic (islamic-umalqura) 1445 AH ยท ~Shawwal 23 Hebrew (hebrew) 5784 ยท ~23 Nisan Japanese era boundary 2019-05-01 Heisei 31 Reiwa 1 eraYear resets to 1 ISO year stays 2019

API reference

Member Signature / value Returns Calendar / timezone caveat
Temporal.PlainDate.from from({ year, month, day, calendar }) PlainDate year is the ISO year; pass a CLDR calendar string
PlainDate.prototype.era property string | undefined Only defined for calendars with eras (gregory, japanese)
PlainDate.prototype.eraYear property number | undefined Resets at each era boundary; pair with era
PlainDate.prototype.withCalendar withCalendar(id) PlainDate Re-projects the same day onto another calendar
Temporal.PlainYearMonth.from from({ year, month, calendar }) PlainYearMonth inLeapYear is calendar-specific (e.g. Hebrew 13-month years)
PlainDate.prototype.toZonedDateTime toZonedDateTime({ timeZone, plainTime?, disambiguation? }) ZonedDateTime DST gaps/overlaps resolved by disambiguation
Intl.DateTimeFormat new Intl.DateTimeFormat(locale, { calendar, era }) formatter Cache it; pass explicit timeZone for server rendering

A note on naming: Temporal.Calendar as an object was removed from the final spec. Calendars are now plain string IDs passed directly to from() and withCalendar().

Approach A: legacy Date (and why it stops short)

The only non-Gregorian rendering legacy code can do is delegate to Intl.DateTimeFormat, which itself is CLDR-backed. You can format an era-aware string, but the Date object underneath still holds no calendar or era data.

// Legacy: Date has no calendar awareness โ€” Intl does the heavy lifting.
const d = new Date(Date.UTC(2024, 4, 1)); // month is 0-indexed: 4 = May

const jp = new Intl.DateTimeFormat('en-US', {
  calendar: 'japanese',
  era: 'short',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  timeZone: 'UTC', // pin to UTC so the wall-clock day does not shift by host zone
});
console.log(jp.format(d)); // 'May 1, 6 Reiwa'

// But you CANNOT ask the Date itself for its era โ€” there is no such property:
console.log((d as any).era); // undefined โ€” Date is Gregorian-only internally

Limitations: you cannot do era-aware arithmetic, you cannot read eraYear back off the value, and constructing a date from an era ("Reiwa 6, month 5, day 1") requires a manual lookup table that breaks the next time an era changes. Legacy Date is display-only for calendars.

Approach B: Temporal calendar-aware types

Temporal stores the calendar with the value, so the same object answers in both ISO and era terms. The year getter is always the ISO/astronomical year; era and eraYear give the calendar-local view.

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

// Build from the Japanese calendar; year here is the ISO year (2024).
const jpDate = Temporal.PlainDate.from({
  year: 2024,
  month: 5,
  day: 1,
  calendar: 'japanese',
});

console.log(jpDate.era);     // 'reiwa'
console.log(jpDate.eraYear); // 6  โ€” Reiwa 6
console.log(jpDate.year);    // 2024 โ€” ISO year, always present

// Re-project the SAME day onto another calendar with withCalendar:
const hijri = jpDate.withCalendar('islamic-umalqura');
console.log(hijri.year, hijri.month, hijri.day); // 1445, 10, 23 (โ‰ˆ 23 Shawwal 1445)

Because the day is anchored on an absolute civil date, withCalendar is loss-free: you are relabeling the same point in the proleptic-day sequence, not converting numbers by hand. For deep dives on these conversions, see convert Gregorian to Japanese era dates and display Islamic (Hijri) dates in JavaScript.

Era arithmetic and astronomical year numbering

The gregory calendar uses astronomical year numbering, which includes a year 0. This matches ISO 8601 extended format and avoids the classic BCE off-by-one:

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

// The Ides of March, 44 BCE = year -43 (because year 0 exists).
const ides = Temporal.PlainDate.from({
  year: -43,
  month: 3,
  day: 15,
  calendar: 'gregory',
});

const shifted = ides.add({ years: 100 }); // -43 + 100 = 57
console.log(shifted.year);    // 57
console.log(shifted.era);     // 'ce'
console.log(shifted.eraYear); // 57

Arithmetic on Japanese dates is subtler: adding years can cross the Heisei โ†’ Reiwa boundary, so eraYear does not advance linearly even though the ISO year does. Always read era/eraYear back after the operation rather than incrementing them yourself.

Production implementation

A single utility that validates calendar input, renders an era-aware label, and exposes the underlying ISO values. Formatter instances are cached because Intl.DateTimeFormat construction is expensive.

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

type SupportedCalendar =
  | 'gregory' | 'japanese' | 'hebrew' | 'islamic-umalqura';

const SUPPORTED = new Set<SupportedCalendar>([
  'gregory', 'japanese', 'hebrew', 'islamic-umalqura',
]);

// Cache formatters by locale+calendar key โ€” construction is the slow part.
const fmtCache = new Map<string, Intl.DateTimeFormat>();

function getFormatter(locale: string, calendar: SupportedCalendar) {
  const key = `${locale}|${calendar}`;
  let f = fmtCache.get(key);
  if (!f) {
    f = new Intl.DateTimeFormat(locale, {
      calendar,
      era: 'long',
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      timeZone: 'UTC', // explicit zone: server rendering must not drift to host zone
    });
    fmtCache.set(key, f);
  }
  return f;
}

export interface CalendarLabel {
  iso: string;        // canonical ISO date
  era?: string;       // e.g. 'reiwa' (undefined for era-less calendars)
  eraYear?: number;   // e.g. 6
  display: string;    // localized, era-aware string
}

export function renderCalendarDate(
  isoDate: string,                 // 'YYYY-MM-DD'
  calendar: SupportedCalendar,
  locale = 'en-US',
): CalendarLabel {
  if (!SUPPORTED.has(calendar)) {
    throw new RangeError(`Unsupported calendar: ${calendar}`);
  }
  // PlainDate.from throws on a malformed/out-of-range ISO date โ€” let it.
  const base = Temporal.PlainDate.from(isoDate).withCalendar(calendar);

  // Intl.format wants a Date; pin to UTC midnight so no day shift occurs.
  const asDate = new Date(Date.UTC(base.year, base.month - 1, base.day));

  return {
    iso: base.withCalendar('iso8601').toString(),
    era: base.era,
    eraYear: base.eraYear,
    display: getFormatter(locale, calendar).format(asDate),
  };
}

// renderCalendarDate('2024-05-01', 'japanese')
//   -> { iso: '2024-05-01', era: 'reiwa', eraYear: 6, display: 'May 1, 6 Reiwa' }

SSR / serverless notes: the timeZone: 'UTC' option is mandatory on the server, otherwise the formatter inherits the container's host zone and dates near midnight can render one day off. The module-level fmtCache is safe to keep across warm invocations in a single isolate; in a worker pool each isolate keeps its own copy, which is fine.

Edge cases

Era boundary day (Heisei โ†’ Reiwa)

April 30 2019 is Heisei 31; May 1 2019 is Reiwa 1. The ISO year is 2019 on both sides. Code that derives a Japanese label from the ISO year alone will mislabel one side of the boundary โ€” always read era/eraYear from the Temporal value.

import { Temporal } from '@js-temporal/polyfill';
const last = Temporal.PlainDate.from('2019-04-30').withCalendar('japanese');
const first = Temporal.PlainDate.from('2019-05-01').withCalendar('japanese');
console.log(last.era, last.eraYear);   // 'heisei' 31
console.log(first.era, first.eraYear);  // 'reiwa' 1

Hebrew leap year (13-month years)

A Hebrew leap year inserts Adar I before Adar II. Never test it with Gregorian modulo-4 logic; use inLeapYear, which encodes the 19-year Metonic cycle.

import { Temporal } from '@js-temporal/polyfill';
const hy = Temporal.PlainYearMonth.from({ year: 5784, month: 1, calendar: 'hebrew' });
console.log(hy.inLeapYear); // true โ€” 5784 is a leap year (has Adar I + Adar II)

For the Gregorian counterpart of this rule, see leap year calculation algorithms.

DST gap when projecting a calendar date to a zone

Calendar transitions and DST live on different axes: DST only affects absolute time (ZonedDateTime/Instant), never civil dates (PlainDate). The DST question only arises when you attach a wall-clock time and a zone.

import { Temporal } from '@js-temporal/polyfill';
const plainDate = Temporal.PlainDate.from('2024-03-10'); // ISO calendar

// 02:30 on 2024-03-10 does not exist in America/New_York (spring-forward gap).
try {
  plainDate.toZonedDateTime({
    timeZone: 'America/New_York',
    plainTime: Temporal.PlainTime.from('02:30'),
    disambiguation: 'reject', // 'reject' throws inside the gap instead of guessing
  });
} catch (err) {
  console.error('Non-existent local time', err); // RangeError
}

// 'compatible' (the default) skips forward to 03:30.
const z = plainDate.toZonedDateTime({
  timeZone: 'America/New_York',
  plainTime: Temporal.PlainTime.from('02:30'),
  disambiguation: 'compatible', // advances out of the gap; 'earlier'/'later' resolve overlaps
});
console.log(z.toString()); // '2024-03-10T03:30:00-04:00[America/New_York]'

Islamic calendar variant divergence

islamic-umalqura (Umm al-Qura, used in Saudi Arabia) and islamic-tbla (tabular) can disagree by a day for the same instant. Pick one variant explicitly and keep it consistent across your stack โ€” never mix variants between server and client.

Gotchas and common pitfalls

Testing checklist

Scenario Input Expected
Reiwa era label '2024-05-01', japanese era reiwa, eraYear 6
Era boundary, last Heisei day '2019-04-30', japanese era heisei, eraYear 31
Era boundary, first Reiwa day '2019-05-01', japanese era reiwa, eraYear 1
BCE astronomical year { year: 0, calendar: 'gregory' } era bce, eraYear 1
Hebrew leap year { year: 5784, calendar: 'hebrew' } inLeapYear === true
Spring-forward gap 02:30 America/New_York, reject throws RangeError
Hijri round-trip '2024-05-01' โ†’ islamic-umalqura year 1445, month 10, day 23

Run the suite under several host zones to prove the UTC pinning holds:

# CI matrix: the calendar output must be identical regardless of host zone.
for tz in UTC America/New_York Asia/Tokyo Asia/Riyadh; do
  TZ=$tz npx jest calendar-systems
done

Frequently Asked Questions

Does the Temporal API support Islamic and Hebrew leap months?

Yes. Temporal exposes CLDR-backed calendars including hebrew, islamic-umalqura, and islamic-tbla. Temporal.PlainYearMonth.inLeapYear reflects calendar-specific leap rules, such as the insertion of Adar I in a 13-month Hebrew year.

How do I avoid off-by-one errors at the BCE/CE boundary?

Use astronomical year numbering in the gregory calendar: year 0 is 1 BCE and year -1 is 2 BCE. Construct with PlainDate.from({ year, calendar: 'gregory' }) and verify with the era and eraYear getters instead of doing manual offset math.

Why is jpDate.year 2024 instead of 6?

year is always the ISO/astronomical year. The calendar-local year lives on eraYear (6 for Reiwa 6), paired with era (reiwa). Use eraYear/era for display and year for ISO interchange.

Can I convert a Temporal calendar date back to a legacy Date?

Yes, with an explicit zone resolution step: const d = new Date(plainDate.toZonedDateTime('UTC').epochMilliseconds). The Date loses all calendar/era metadata, so keep the Temporal value as the source of truth.

Does DST affect calendar system conversions?

No. DST only shifts absolute time (ZonedDateTime/Instant); civil calendar dates (PlainDate) have no time component. DST only enters when you attach a plainTime and zone via toZonedDateTime, where disambiguation resolves gaps and overlaps.