JavaScriptで西暦を和暦に変換|令和元年・境界日・年だけ変換の罠
JavaScript で「2019-05-01」を「令和元年5月1日」と表示したい、というのは日本向けのフォームや帳票を作ると必ず出てくる要件です。結論から言うと、年月日が揃っている日付なら Intl.DateTimeFormat に ja-JP-u-ca-japanese を指定するのが最短です。ライブラリは要りません。
ただし、「年だけ」の変換や境界日、タイムゾーンが絡むと、そのままでは間違った和暦を出します。この記事では Node.js v20.20.1(ICU 78.2)で実際に動かした結果をもとに、書き方と落とし穴を整理します。ブラウザ各種や古い Node.js は検証していません。
結論:最短の書き方と、先に知っておきたい注意点
まず、動かして確認できた最小のコードです。
const fmt = new Intl.DateTimeFormat("ja-JP-u-ca-japanese", {
dateStyle: "long",
timeZone: "Asia/Tokyo", // 必ず指定する(理由は後述)
});
fmt.format(new Date("2019-05-01T12:00:00+09:00")); // "令和元年5月1日"
fmt.format(new Date("2019-04-30T12:00:00+09:00")); // "平成31年4月30日"
fmt.format(new Date("1989-01-07T12:00:00+09:00")); // "昭和64年1月7日"
fmt.format(new Date("1989-01-08T12:00:00+09:00")); // "平成元年1月8日"
注意点を先に表にします。
| 落とし穴 | 何が起きるか | 対処 |
|---|---|---|
| タイムゾーン未指定 | 同じ瞬間でも実行環境によって元号が変わる | timeZone: "Asia/Tokyo" を指定する |
| 西暦の「年だけ」で変換 | 2019年や1989年は元号が2通りあり、一意に決まらない | 月日まで持つ。持てないなら両方を併記する |
| 範囲外の日付 | 1850年が「嘉永」、50年が「大化-594年」などになる | 対応範囲を決めて、範囲外は弾く |
| 未来の日付 | 2100年が「令和82年」になる(将来の改元は反映できない) | 将来日付は和暦表示しない、または注意書きを出す |
| 環境依存の表記 | 「元年」「R1/5/1」などの出力はICUのバージョンで変わりうる | 業務で固定したいなら自前テーブルにする |
Intl.DateTimeFormat で和暦に変換する方法
Intl.DateTimeFormat のロケールに -u-ca-japanese という拡張を付けると、和暦(日本の暦)で出力されます。ja-JP だけでは西暦のままです。
const d = new Date("2019-05-01T12:00:00+09:00");
const opt = { timeZone: "Asia/Tokyo" };
new Intl.DateTimeFormat("ja-JP", { era: "long", year: "numeric", ...opt }).format(d);
// "西暦2019年" ← 拡張なしだと和暦にならない
new Intl.DateTimeFormat("ja-JP-u-ca-japanese", { era: "long", year: "numeric", ...opt }).format(d);
// "令和元年"
new Intl.DateTimeFormat("ja-JP", { calendar: "japanese", era: "long", year: "numeric", ...opt }).format(d);
// "令和元年"(calendar オプションでも同じ結果)
出力形式ごとの実測結果です(2019年5月1日、Node.js v20.20.1)。
| 指定 | 出力 |
|---|---|
dateStyle: "long" | 令和元年5月1日 |
dateStyle: "full" | 令和元年5月1日水曜日 |
era: "short", year: "numeric" | 令和元年 |
era: "narrow", year: "numeric" | R元年 |
year, month, day を "numeric" | R1/5/1 |
era: "long" と year/month/day を "2-digit" | 令和01/05/01 |
文字で出す形式では年が「元」になり、数字だけの形式では「1」になる、という使い分けを ICU が行っています。帳票で「令和元年」と書きたいのか「令和1年」と書きたいのかは要件次第なので、出力形式を決めてから指定を選ぶのが安全です。
部品に分けて自分で組み立てる
「元」を数字の1にしたい、年号だけ別の要素にしたい、という場合は formatToParts で部品を取り出せます。
const parts = new Intl.DateTimeFormat("ja-JP-u-ca-japanese", {
era: "long",
year: "numeric",
timeZone: "Asia/Tokyo",
}).formatToParts(new Date("2019-05-01T12:00:00+09:00"));
// [ { type: 'era', value: '令和' },
// { type: 'year', value: '元' },
// { type: 'literal', value: '年' } ]
year の値は「元」という文字列で返ります。後で数値として扱おうとして Number("元") や parseInt("元") を呼ぶと NaN になります(実測)。足し算や比較に使うつもりなら、「元」を1に置き換える処理が必要です。
落とし穴1:タイムゾーンで元号が変わる
最も見落としやすいのがこれです。同じ瞬間(2019年4月30日 20:00 UTC=日本時間の5月1日5:00)を、タイムゾーンだけ変えて整形した結果です。
| timeZone | 出力 |
|---|---|
Asia/Tokyo | 令和元年5月1日 |
America/Los_Angeles | 平成31年4月30日 |
timeZone を省略すると、実行環境(ブラウザなら利用者の端末、サーバーならサーバー設定)のタイムゾーンが使われます。日本のユーザーの端末だけで動かすなら問題が表に出にくいのですが、サーバーが UTC 設定のとき、日本時間の早朝(0時〜9時)にだけ前日扱いになる、といった形で顕在化します。元号の切り替わり日と重なると、元号まで変わってしまいます。
また、日付だけの文字列は UTC 0時として解釈されます。
new Date("2019-05-01").toISOString(); // "2019-05-01T00:00:00.000Z"
これを日本時間で整形すれば9時になるので日付は変わりませんが、西側のタイムゾーンで整形すると前日になります。日付がずれる原因の切り分けは JavaScriptの日付が1日前になる原因 で別途実測しているので、あわせて参照してください。
落とし穴2:「年だけ」の変換は一意に決まらない
「生年(西暦)から和暦を出す」「西暦年だけ入力されたものを和暦にする」という処理は、次のように書きがちです。
// ダメな例:年だけで判定している
const naive = (y) => (y >= 2019 ? `令和${y - 2018}年` : y >= 1989 ? `平成${y - 1988}年` : "...");
naive(2019); // "令和1年" … 4月までは平成31年
naive(1989); // "平成1年" … 1月7日までは昭和64年
naive(2018); // "平成30年"
元号は年の途中で切り替わるため、境界の年は2通りに分かれます。
| 西暦 | 前半の元号 | 後半の元号 | 切り替わり日 |
|---|---|---|---|
| 2019年 | 平成31年 | 令和元年 | 5月1日 |
| 1989年 | 昭和64年 | 平成元年 | 1月8日 |
| 1926年 | 大正15年 | 昭和元年 | 12月25日 |
| 1912年 | 明治45年 | 大正元年 | 7月30日 |
特に1989年は、昭和64年が1月1日から7日までの7日間しかありません。この7日間に生まれた人は「昭和64年生まれ」ですが、年だけで処理していると全員が「平成元年」と判定されてしまいます。
対処は、可能なら月日まで持つことです。年しか手元に無い場合は、境界年に限って「平成31年/令和元年」のように併記する、または入力欄で元号を選ばせる設計にすると、誤りを出さずに済みます。
落とし穴3:範囲外の日付が、もっともらしい文字列で返る
Intl は例外を出さず、範囲外の日付でも何かしらの文字列を返します。実測した結果です。
| 日付 | 出力 | 補足 |
|---|---|---|
| 1850-01-01 | 嘉永3年1月1日 | 明治より前の元号になる |
| 1868-09-08 | 慶応4年9月8日 | 明治の開始は 1868-10-23 と出る |
| 1872-12-31 | 明治5年12月31日 | 太陽暦の導入(明治6年1月1日)より前 |
| 0050-01-01 | 大化-594年1月3日 | 年が負の数になる |
| 2100-01-01 | 令和82年1月1日 | 未来の改元は反映できない |
明治6年(1873年)1月1日より前は、日本が旧暦(太陽暦導入前の暦)を使っていた時期です。Intl が返す「明治5年12月31日」は、現在の暦をさかのぼって当てはめた表示と考えられ、当時の人が実際に使っていた日付とは一致しません。歴史的に正確な和暦が必要な用途(古い戸籍や史料など)では、この出力を使わず、専門の資料にあたってください。
実用上は、対応範囲を「明治6年1月1日〜今日まで」と決めて、それ以外は変換せずに入力エラーにするのが無難です。生年月日の入力であれば、これで困る場面はほとんど無いはずです。
落とし穴4:出力表記は環境で変わりうる
今回の Node.js v20.20.1 では、2019年5月1日が「令和元年5月1日」と出ました。一方で、検索で見つかる過去の解説には「令和1年」「令和02年」といった出力例も載っています。ICU(ブラウザや Node.js が内蔵している国際化データ)のバージョンによって、元年の表記や数字の桁が変わる可能性があると考えられます。ただし、古い環境での挙動は今回確認していないため、断定はできません。
業務システムの帳票のように「表記を固定したい」「環境が変わっても同じ出力にしたい」という場合は、Intl に任せず、次の自前テーブルを使うほうが確実です。
自前の変換テーブル(Date を使わない版)
元号の開始日をテーブルで持ち、Date オブジェクトを経由せずに年月日の数値だけで判定する方法です。タイムゾーンの問題が原理的に起きません。
// 開始日の新しい順に並べる
const ERAS = [
{ name: "令和", start: [2019, 5, 1] },
{ name: "平成", start: [1989, 1, 8] },
{ name: "昭和", start: [1926, 12, 25] },
{ name: "大正", start: [1912, 7, 30] },
{ name: "明治", start: [1868, 10, 23] },
];
const MIN = 18730101; // 明治6年1月1日(太陽暦の導入)より前は対応しない
const key = (y, m, d) => y * 10000 + m * 100 + d;
function toWareki(y, m, d) {
const k = key(y, m, d);
if (k < MIN) return null; // 範囲外
for (const e of ERAS) {
if (k >= key(...e.start)) {
const n = y - e.start[0] + 1;
return { era: e.name, year: n, text: `${e.name}${n === 1 ? "元" : n}年${m}月${d}日` };
}
}
return null;
}
toWareki(2019, 4, 30).text; // "平成31年4月30日"
toWareki(2019, 5, 1).text; // "令和元年5月1日"
toWareki(1989, 1, 7).text; // "昭和64年1月7日"
toWareki(1989, 1, 8).text; // "平成元年1月8日"
toWareki(1872, 12, 31); // null
このコードを、Intl.DateTimeFormat(ja-JP-u-ca-japanese、dateStyle: "long"、timeZone: "UTC")の出力と、1873年1月1日から2026年12月31日までの全56,247日分で突き合わせたところ、不一致は0件でした(Node.js v20.20.1 での実測)。
なお、明治の年数は開始日(1868年)を基準に数える必要があります。試作の段階で、明治の開始日を1873年にして年数の基準までずらしたところ、14,454日分が不一致になりました。テーブルの開始日と、年数の起点を分けて考えないことが大切です。このコードでは、起点を start[0](1868年)とし、対応範囲の下限は別の定数 MIN で持つことで両立させています。
将来の改元に備える
新しい元号が決まったら、ERAS の先頭に1行足すだけで対応できます。Intl は実行環境の ICU が更新されるまで新元号を知らないので、改元後しばらくは Intl と自前テーブルで結果が食い違う期間が出る可能性があります。公的な書類を扱うシステムでは、この点も運用に含めておくとよいでしょう。
和暦から西暦への逆変換
フォームに「令和元年5月1日」と入力された文字列を西暦に直すときも、「元」の扱いが罠になります。
const BASE = { 令和: 2018, 平成: 1988, 昭和: 1925, 大正: 1911, 明治: 1867 };
function fromWareki(text) {
const m = text.match(/(令和|平成|昭和|大正|明治)(元|\d+)年(\d+)月(\d+)日/);
if (!m) return null;
const n = m[2] === "元" ? 1 : Number(m[2]);
return { y: BASE[m[1]] + n, m: Number(m[3]), d: Number(m[4]) };
}
fromWareki("令和元年5月1日"); // { y: 2019, m: 5, d: 1 }
ここで Number("元") をそのまま呼ぶと NaN になるため、「元」を1に置き換える分岐が必須です。さらに、入力された和暦が実在するかの検証も要ります。「平成31年5月1日」は文字列としては正しい形ですが、実際には存在しない日付(平成は4月30日まで)です。逆変換した結果を toWareki に通して、元の入力と一致するか確認する方法が簡単です。
const r = fromWareki("平成31年5月1日");
toWareki(r.y, r.m, r.d).text; // "令和元年5月1日" → 入力と違うので不正と判断できる
どちらを選ぶか
| 条件 | おすすめ |
|---|---|
| 画面表示だけ。多少の表記ゆれは許容できる | Intl.DateTimeFormat(timeZone を必ず指定) |
| 帳票・書類。表記を固定したい | 自前テーブル |
| 入力された和暦を検証したい | 自前テーブル(逆変換→再変換で一致確認) |
| 年だけしか持っていない | 変換しない、または境界年は併記する |
まとめ
- 年月日が揃っているなら、
ja-JP-u-ca-japaneseのIntl.DateTimeFormatで和暦に変換できる。 timeZone: "Asia/Tokyo"を指定しないと、同じ瞬間でも環境によって元号が変わる。実測では、同じ瞬間が日本で「令和元年5月1日」、ロサンゼルスで「平成31年4月30日」になった。- 西暦の「年だけ」では、2019年や1989年のような境界年の元号が決まらない。月日まで持つか、併記する。
Intlは範囲外でもそれらしい文字列を返す。明治6年より前や将来の日付は、入力エラーにする。- 表記を固定したい場面では、
Dateを使わず数値だけで判定する自前テーブルが確実。全56,247日でIntlと一致することを確認した。
本記事の出力例は Node.js v20.20.1(ICU 78.2)での実測で、各ブラウザや他のバージョンでの出力は確認していません。公的な書類や契約など、和暦の表記に厳密さが求められる用途では、提出先の書式や指示を優先して確認してください。