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.
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:
- Year 1 CE โ
year: 1 - Year 1 BCE โ
year: 0 - Year 2 BCE โ
year: -1
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
- Reading the ISO
yearand assuming it is the era year.jpDate.yearis 2024, not 6. Fix: useeraYearfor era-local display. - Incrementing
eraYearmanually across a boundary. Era years reset (Heisei 31 โ Reiwa 1) while the ISO year is continuous. Fix: do arithmetic on the Temporal value, then re-readera/eraYear. new Temporal.Calendar(id). The object was removed from the final spec. Fix: pass the calendar string ID directly tofrom()/withCalendar().- Formatting a
PlainDateby passing it straight toIntl.Intl.DateTimeFormat.format()takes aDate, not a Temporal object. Fix: buildnew Date(Date.UTC(d.year, d.month - 1, d.day)). - Modulo-4 leap logic on non-Gregorian years. Fix: use
Temporal.PlainYearMonth.inLeapYear, which is calendar-correct.
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.
Related
- Modern Date Logic with the Temporal API โ parent overview.
- Getting Started with the Temporal API โ install the polyfill and learn the type model.
- Convert Gregorian to Japanese Era Dates
- Display Islamic (Hijri) Dates in JavaScript
- Leap Year Calculation Algorithms โ the Gregorian leap rule for comparison.