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

The gateway to non-Gregorian calendars is withCalendar(id), which re-interprets an existing date in a named calendar without changing the instant it represents β€” the same day, read through a different calendar's fields. Once re-interpreted, year, month, day, era, eraYear, monthCode, daysInMonth, and daysInYear all report values in that calendar, while the ISO fields remain reachable so you always have an unambiguous canonical form. Calendar identifiers follow the CLDR names β€” 'japanese', 'islamic-umalqura', 'hebrew', 'buddhist', 'persian', and others β€” and the underlying data comes from the platform's internationalization tables rather than from your code.

Two fields deserve special attention because they exist precisely to handle calendars that Gregorian intuitions break on. monthCode is a string identifier for a month that stays stable even in calendars with leap months (the Hebrew calendar inserts an entire extra month in some years), where a bare numeric month would be ambiguous. And the era/eraYear pair expresses calendars like the Japanese one that count years within named eras rather than continuously. Reading these through the API means you never reimplement calendar rules; you consult data that is maintained centrally and updated as calendars evolve.

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().

Calendar APIsWorking with non-Gregorian calendarsCalendar APIsPlainDate.withCalendar('hebrew')reinterpret in another calendardate.era / date.eraYearJapanese/ROC era fieldsdate.monthCodeleap months like 'M05L'Intl.DateTimeFormat calendarlocalized era display

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.

Legacy DateDate is Gregorian-only; eras need manual tablesLegacyModernonly proleptic Gregorianhand-code era rangesno leap-month conceptbrittle, incompletewithCalendar('japanese')era + eraYear built inmonthCode for leapscalendar-aware

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.

Reinterpret in a calendarOne instant, viewed through any calendar systemReinterpret in a calendarPlainDatewithCalendar('islamic')era/year/month fields

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.

Calendar-safe utilityValidate the calendar id, read era fields, format with IntlCalendar-safe utilitydate + calendarwithCalendarera fieldsIntl display

Edge cases

Leap months are the edge case that most cleanly separates calendar-aware code from Gregorian-assuming code. The Hebrew calendar, for instance, adds a thirteenth month in seven years out of every nineteen to keep its lunar months aligned with the solar year, so a naive month + 1 can land in a month that does not exist in some years and shifts the meaning of every later month. This is exactly why monthCode exists: it identifies a month in a way that survives the insertion, so arithmetic and comparison stay correct across leap and non-leap years. Any code that touches non-Gregorian months should prefer monthCode over the numeric index.

Era boundaries are the other major edge case, and they rarely align with anything convenient. The Japanese Reiwa era began mid-year on 1 May 2019, so an era-year offset computed from the Gregorian year is wrong for dates near the boundary. Historical dates raise a subtler issue: the proleptic Gregorian calendar that ISO uses extends backward past the calendar's actual adoption, so a "Gregorian" date before the 1582 reform is a mathematical projection, not what people alive then would have written. When historical accuracy matters, be explicit about which calendar a stored date is in, and lean on the calendar data rather than assuming Gregorian rules held universally.

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.

Calendar edge casesWhere calendars divergeCalendar edge casesEra boundarymid-year changeeraYear resetsLeap monthsmonthCode 'M05L'not month 13Variant calendarsumalqura vs civildiffer by a day

Gotchas and common pitfalls

Calendar pitfallsAssuming Gregorian everywhereWrongRightmonth number across calendarsuse monthCodewithCalendar + era fieldscalendar-awarehard-code era rangesbreaks on changelet Temporal supply erasfuture-proof

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

Calendar test matrixEra and calendar conversions assert correctlyAssertions that prove the edge caseGregorian→Japanese 2019Reiwa 1hijri variantdiffers by a dayleap monthmonthCode LIntl eralocalized

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.

How do I display a date in a non-Gregorian calendar with Temporal?

Call withCalendar(id) on a PlainDate β€” for example withCalendar('hebrew') or withCalendar('islamic-umalqura') β€” and read year, month, day, era, eraYear, and monthCode, which then report values in that calendar while the ISO fields remain available. The calendar identifiers follow CLDR names and the conversion uses the platform's internationalization data, so you never reimplement the calendar's rules. For formatted output, Intl.DateTimeFormat with the matching calendar renders localized month and era names.

What is monthCode and why does it matter?

monthCode is a stable string identifier for a month that stays correct even in calendars with leap months. The Hebrew calendar inserts an entire extra month in some years, so a bare numeric month index is ambiguous β€” the same number means different months in leap and non-leap years. monthCode identifies the month unambiguously across both, so arithmetic and comparison in such calendars should use it rather than the numeric month to avoid shifting every later month.

Why don't Japanese era years align with Gregorian years?

Because eras change on dates set by imperial succession, not on January 1. The Reiwa era began on 1 May 2019, so its boundary falls in the middle of a Gregorian year and no fixed offset from the Gregorian year gives the correct era year for every date. Temporal reads the exact era start dates from CLDR data via withCalendar('japanese'), so the era and eraYear are correct even for dates right around the transition.