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 part | Number() |
|---|---|---|
| 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-01 | 2019/04/30 | 平成31年 |
| 1989-01-08 | 1989/01/07 | 昭和64年 |
| 1868-10-23 | 1868/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-29 | 2025-02-28 | 24 | 25 |
| 2000-02-29 | 2025-03-01 | 25 | 25 |
| 2000-04-01 | 2025-03-31 | 24 | 25 |
| 2000-04-01 | 2025-04-01 | 25 | 25 |
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つ出すことにした。