本文へ移動
myowchick

Intlの和暦は1年目を「元」で返す

西暦から和暦を出すのにIntl.DateTimeFormatの和暦カレンダーを使うと、改元日の表を自前で持たずに済む。

ただしformatToPartsのyearをNumber()に通すと、平成元年と令和元年だけNaNになる。

1年目の年が数字ではなく「元」という文字列で返ってくるからだ。

西暦和暦変換ツールを作ったときに踏んだので、周りの癖もまとめて実測してみた。

改元日の表は要らない

和暦変換で面倒なのは、改元が年の途中で起きること。

昭和から平成は1989年1月8日、平成から令和は2019年5月1日で切り替わる。日単位の表を自分で持つと、改元のたびに直すことになる。

これはIntlに任せられる。

const format = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
  era: 'long',
  year: 'numeric',
})

format.format(new Date(2019, 3, 30)) // '平成31年'
format.format(new Date(2019, 4, 1)) // '令和元年'

境界を実測したら、ICUのデータは日単位で正しかった。明治・大正・昭和の改元日も合っている。

日付出力
1868-10-22慶応4年
1868-10-23明治元年
1912-07-29明治45年
1912-07-30大正元年
1926-12-24大正15年
1926-12-25昭和元年
1989-01-07昭和64年
1989-01-08平成元年
2019-04-30平成31年
2019-05-01令和元年

1年目のyearは「元」

表示用の文字列を作るだけならformatで終わる。年の数値が欲しくなるとformatToPartsを使うことになって、そこで踏む。

const parts = format.formatToParts(new Date(1989, 0, 8))
parts.find((part) => part.type === 'year').value // '元'

Number('元')はNaN。実測はこうなった。

日付year partNumber()
1989-01-07'64'64
1989-01-08'元'NaN
2019-04-30'31'31
2019-05-01'元'NaN

year: '2-digit'にしても、eraをshortやnarrowにしても「元」のまま。nu-latnで数字体系をラテン数字に指定しても変わらない。narrowにするとH元年という取り合わせになるだけだった。

数字にしたければロケールを変える手はある。

new Intl.DateTimeFormat('en-US-u-ca-japanese', { era: 'long', year: 'numeric' })
  .formatToParts(new Date(1989, 0, 8))
  .find((part) => part.type === 'year').value // '1'

ただしこれはICUのデータ次第で、将来も両方こうだと約束されているわけではないと思う。素直に両方読むことにした。

const year = rawYear === '元' ? 1 : Number(rawYear)

慶応も返ってくる

Intlの和暦カレンダーは明治で止まらない。1868年1月25日を渡すと慶応4年が返る。

もっと遡っても返し続ける。

西暦出力
1500明応9年
1200正治2年
900昌泰3年
645大化元年
600大化-44年
1大化-643年

大化より前は年が負になって、元号としては意味の無い文字列になる。

厄介なのは、明治より前を落とすつもりで年だけを見ると外れること。1868年は10月22日まで慶応4年なので、「1868年以降なら明治」は成り立たない。

年ではなく元号名で判定するのが確実だった。

const ERAS = ['明治', '大正', '昭和', '平成', '令和']
if (!ERAS.includes(era)) {
  return null
}

逆向きは年でしか決まらない

西暦から和暦は日単位で決まるのに、和暦から西暦は日単位で決まらない。

「令和6年」と言われても、それが何月何日かは分からない。分かるのは西暦2024年ということだけ。

つまり順方向と逆方向で粒度が違う。だから逆変換は、元号の開始年だけを持つ年単位の表に落ちる。

const ERAS = [
  { name: '明治', startYear: 1868 },
  { name: '大正', startYear: 1912 },
  { name: '昭和', startYear: 1926 },
  { name: '平成', startYear: 1989 },
  { name: '令和', startYear: 2019 },
]

改元の年は前後どちらの元号でも名乗れるので、往復させると元に戻らない。

昭和64年 -> 西暦1989年 -> その年の和暦 [昭和64年 / 平成元年]
平成元年 -> 西暦1989年 -> その年の和暦 [昭和64年 / 平成元年]
明治45年 -> 西暦1912年 -> その年の和暦 [明治45年 / 大正元年]

これは実装の粗ではなくて、和暦から日が復元できない以上そうなる。ツールでは改元の年だけ2つ並べて出すことにした。

date入力の値をnew Date()に渡さない

<input type="date">のvalueは2019-05-01のような文字列で返る。これをそのままnew Date()に渡すと、日付だけの形はUTCとして解釈される。

日本標準時ならUTC+9なので同じ日のままだけど、UTCより西のタイムゾーンでは1日前になる。改元日ちょうどを入れたときに元号が変わってしまう。

TZ=America/New_Yorkで実測した。

入力new Date(value)和暦
2019-05-012019/04/30平成31年
1989-01-081989/01/07昭和64年
1868-10-231868/10/22慶応4年

3件とも1つ前の元号になった。Pacific/Honoluluでも同じ結果。

自分で分解してローカル時刻の0時として作れば済む。

const [year, month, day] = value.split('-').map(Number)
const date = new Date(year, month - 1, day)

ただしこの形にも罠が2つある。

1つ目は、0から99の年が1900年代に寄ること。new Date(89, 0, 8)は1989年1月8日になる。DateとIntlで踏んだ5つの罠で書いたのと同じ癖で、date.setFullYear(year)を後から呼んで外す。

2つ目は、暦に無い日付が黙って繰り上がること。new Date(2026, 1, 30)は2026年3月2日になる。作った後に年月日を入力と突き合わせて、ずれていたら弾く。

干支の剰余は負を返す

ついでに干支も出すことにした。十二支は西暦4年が子年なので、(year - 4) % 12で引ける。

JavaScriptの%は剰余ではなく余りで、左辺が負なら結果も負になる。

(2026 - 4) % 12 // 6
(0 - 4) % 12 // -4

配列の添字に負が来るとundefinedが出る。

ETO[(((year - 4) % 12) + 12) % 12]

12を足してもう一度割る、いつものやつで潰す。今のツールは明治より前を扱わないので負の年は来ないけど、干支の関数だけ切り出して使い回すときに効く。

おまけ: 誕生日の前日に歳を取る

年齢計算ニ関スル法律では、誕生日の前日が終わる時に歳を取ることになっている。

日常の感覚とは1日ずれる。2月29日生まれの人が平年に歳を取るのはいつか、という問いに答えるための建て付けで、2月28日の終了時に加齢する。

法律どおりに数えた場合と、誕生日当日に加齢する満年齢を並べるとこうなる。

生年月日基準日満年齢法律
2000-02-292025-02-282425
2000-02-292025-03-012525
2000-04-012025-03-312425
2000-04-012025-04-012525

4月1日生まれが早生まれ扱いになるのはこれのせい。3月31日の終了時に歳を取るので、学年の区切りに間に合う。

ツールは日常の感覚に合う満年齢のほうにした。法律準拠の年齢が要る場面では、そもそも和暦変換ツールを見に来ないと思う。

インスタンスを使い回すと45倍速い

Intl.DateTimeFormatのインスタンスは使い回す。

同じ設定で2万回formatToPartsを呼ぶのに、モジュールの先頭で1つ作って使い回した場合と毎回newした場合を測った。

使い回し: 60ms / 20000回  (3.0us)
毎回生成: 2742ms / 20000回  (137.1us)  -> 45.4倍

45倍違った。入力のたびに変換が走る画面なので、素直に定数にしてある。

改元日という、いちばん面倒で今後もずれうるところはIntlが持っていて、こちらに残ったのは「1年目だけ数字で返らない」という1点だけだった。持たずに済む表は持たない、そのかわり返ってくる値の癖は全部見る、という取引になっている。

逆向きが年でしか決まらないのは実装の粗ではなく、和暦のほうが日付を持っていないから。往復させると改元の年で2つに割れるので、割れたまま2つ出すことにした。

← 一覧へ戻る