Converting Between Japanese Eras and Gregorian Dates — and Why the Boundary Days Matter
Japan's modern era names (Meiji / Taishō / Shōwa / Heisei / Reiwa) don't transition on New Year's Day. They transition on specific dates, which means "Shōwa 64" is a seven-day era and January 7, 1989 is technically the end of it. A converter that doesn't handle this gets the most interesting dates wrong.
Japan officially uses two year-numbering systems: the Gregorian calendar (2026) and era names (令和8年 — Reiwa 8). Most online converters will tell you "Reiwa 8 = 2026" and leave it at that. But historical dates trip on an edge case that most converters get wrong: eras don't transition on a year boundary. Shōwa ended on January 7, 1989. Heisei began on January 8. So "Shōwa 64 January 7" is a valid date — it's the very last day of Shōwa — and "Shōwa 64 January 8" is not.
🔗 Live demo: https://sen.ltd/portfolio/era-converter/
📦 GitHub: https://github.com/sen-ltd/era-converter
Both directions (Gregorian → era, era → Gregorian), accurate boundary day handling, and the 元年 ("first year") shorthand parsing that shows up in official documents. Vanilla JS, zero deps, no build.
Era data as a list of boundary dates
The era definitions store each transition down to the day, not just the year:
export const ERAS = [
{ id: 'meiji', name: '明治', reading: 'めいじ', start: [1868, 10, 23] },
{ id: 'taisho', name: '大正', reading: 'たいしょう', start: [1912, 7, 30] },
{ id: 'showa', name: '昭和', reading: 'しょうわ', start: [1926, 12, 25] },
{ id: 'heisei', name: '平成', reading: 'へいせい', start: [1989, 1, 8] },
{ id: 'reiwa', name: '令和', reading: 'れいわ', start: [2019, 5, 1] },
]
The "Meiji = 1868" shorthand most references use hides an important fact: most of 1868 was still the Edo period. Meiji began on the day the era proclamation took effect, October 23, 1868. A date like March 15, 1868 isn't Meiji 1 at all — it falls under the previous era. Storing only the year loses this information.
Gregorian → era via tuple comparison
Since [year, month, day] tuples compare like a number when walked left-to-right, you can use a simple comparator and search in reverse through the eras list:
function tupleCmp(a, b) {
if (a[0] !== b[0]) return a[0] - b[0]
if (a[1] !== b[1]) return a[1] - b[1]
return a[2] - b[2]
}
export function gregorianToEra(year, month, day) {
const date = [year, month, day]
if (tupleCmp(date, ERAS[0].start) < 0) {
return { error: 'before Meiji' }
}
for (let i = ERAS.length - 1; i >= 0; i--) {
const era = ERAS[i]
if (tupleCmp(date, era.start) >= 0) {
const eraYear = year - era.start[0] + 1
return { era, year: eraYear }
}
}
}
Walk the list from newest to oldest; the first era whose start is <= the input wins. For 1989-01-07, the reverse walk tries Reiwa's start (2019-05-01 — too late), Heisei's start (1989-01-08 — also too late!), and finally Shōwa (1926-12-25 — match). Returns Shōwa year 64.
Era year calculation is year - era.start[0] + 1 — Shōwa started in 1926, so 1989 is Shōwa year 1989 - 1926 + 1 = 64.
Parsing 元年 (first year)
Japanese era strings often use 元 ("origin") instead of 1 for the first year of an era. 平成元年 is Heisei year 1 — this spelling is common in formal documents. The parser accepts both:
export function parseEraString(str) {
const match = /^(明治|大正|昭和|平成|令和)\s*(\d+|元)(?:年(?:\s*(\d+)月)?(?:\s*(\d+)日)?)?$/.exec(
str.trim()
)
if (!match) return { error: 'unrecognized era format' }
const [, name, yearStr, month, day] = match
const era = ERAS.find((e) => e.name === name)
const year = yearStr === '元' ? 1 : Number(yearStr)
return {
era,
year,
month: month ? Number(month) : undefined,
day: day ? Number(day) : undefined,
}
}
The regex alternation (\d+|元) captures either form, and the post-processing normalizes 元 to 1. Month and day are optional so 令和7年 parses successfully (without month/day info) and so does 昭和62年5月3日 (fully specified).
Range checks: Shōwa 65 doesn't exist
Because Shōwa ended in early 1989, "Shōwa 65" is not a valid era year. The converter validates against the next era's start year:
const nextIdx = ERAS.indexOf(era) + 1
if (nextIdx < ERAS.length) {
const nextStart = ERAS[nextIdx].start
if (gregYear > nextStart[0]) {
return {
error: `${era.name} ${eraYear}年 out of range (max ${nextStart[0] - era.start[0] + 1})`
}
}
}
The error message includes the actual maximum ("max 64") so users can correct their input without guessing. Unhelpful range errors ("out of bounds") are one of my pet peeves in form validation — the user already knew they were out of bounds, they need to know by how much.
Tests
19 cases on node --test. The key ones target the boundary days from both sides, because a single off-by-one in >= vs > silently passes half the tests:
test('Shōwa to Heisei boundary: 1989-01-07 is Shōwa 64', () => {
const r = gregorianToEra(1989, 1, 7)
assert.equal(r.era.name, '昭和')
assert.equal(r.year, 64)
})
test('Shōwa to Heisei boundary: 1989-01-08 is Heisei 1', () => {
const r = gregorianToEra(1989, 1, 8)
assert.equal(r.era.name, '平成')
assert.equal(r.year, 1)
})
test('Heisei to Reiwa boundary: 2019-04-30 is Heisei 31', () => {
const r = gregorianToEra(2019, 4, 30)
assert.equal(r.era.name, '平成')
assert.equal(r.year, 31)
})
test('Heisei to Reiwa boundary: 2019-05-01 is Reiwa 1', () => {
const r = gregorianToEra(2019, 5, 1)
assert.equal(r.era.name, '令和')
assert.equal(r.year, 1)
})
test('parse 令和元年 as Reiwa year 1', () => {
const r = parseEraString('令和元年')
assert.equal(r.era.name, '令和')
assert.equal(r.year, 1)
})
Testing both sides of every boundary is the pattern to internalize here. "Last day of Shōwa" and "first day of Heisei" are the exact same == vs < decision inside tupleCmp, and one test on its own can't distinguish a correct implementation from a wrong one.
Series
This is entry #10 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/era-converter
- 🌐 Live: https://sen.ltd/portfolio/era-converter/
- 🏢 Company: https://sen.ltd/
Current scope is Meiji onward. PRs adding older eras welcome.

Top comments (0)