紀元前501年が501になる — DateとIntlで踏んだ5つの罠
紀元前501年を指すunixtimeを入れたら、501-01-01と出た。符号が付かない。
Intl.DateTimeFormatはeraを要求しないと、紀元前の年を正の数で返す。仕様どおりの挙動で、バグではない。
Unixtime変換ツールを作るあいだに、この手の罠を5つ踏んだ。どれも「知っていれば当たり前」の側の話だけど、知らないと出力を見ても気づけない。以下、Node 24.19.0(ICU 78.3)で実測しながら書く。
Intlは紀元前を正の数で返す
まず現物。
const bc = new Date(-77945673600 * 1000)
new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', year: 'numeric' }).format(bc)
// => '501'
bc.toISOString()
// => '-000500-01-01T00:00:00.000Z'
ja-JPにしても501/01/01で、やはり符号は出ない。
eraを足すと分かれる。formatToPartsでいくつか読んでみた。
| unixtime(秒) | era | year | toISOString()の年 |
|---|---|---|---|
| -62135596800 | AD | 1 | 0001 |
| -62167219200 | BC | 1 | 0000 |
| -62198755200 | BC | 2 | -0001 |
| -77945673600 | BC | 501 | -0500 |
西暦1年と紀元前1年がどちらもyear: 1で返っている。eraを外すと本当に区別が付かない。
数え方も違う。紀元前1年の翌年が西暦1年で、0年が無い。ISO 8601のproleptic Gregorianは0年を挟むので、1 - eraYearで移す。紀元前1年が0、紀元前2年が-1、紀元前501年が-500。上の表の右2列がそのまま対応になっている。
const isBc = part('era').startsWith('B')
const year = isBc ? 1 - Number(part('year')) : Number(part('year'))
Date.UTC(26, 0, 1)は1926年になる
年を2桁で渡すと、1900を足して読まれる。
new Date(Date.UTC(26, 0, 1)).toISOString() // '1926-01-01T00:00:00.000Z'
new Date(Date.UTC(99, 0, 1)).toISOString() // '1999-01-01T00:00:00.000Z'
new Date(Date.UTC(0, 0, 1)).toISOString() // '1900-01-01T00:00:00.000Z'
new Date(Date.UTC(100, 0, 1)).toISOString() // '0100-01-01T00:00:00.000Z'
0から99までが対象で、100以上は素通し。境目が99と100のあいだにある。new Date(y, m, d)の側にも同じ規則が効く。
datetime-localの入力欄は0026-01-01T00:00:00という値を平気で返してくるので、ここは普通に踏む。直すなら後から入れ直す。
const d = new Date(Date.UTC(26, 0, 1))
d.setUTCFullYear(26)
d.toISOString() // '0026-01-01T00:00:00.000Z'
ローカル側でsetFullYearを使うと、ついでに知らない情報が出てくる。
new Date(26, 0, 1) -> Fri Jan 01 1926 00:00:00 GMT+0900
setFullYear(26) -> Thu Jan 01 0026 00:00:00 GMT+0918
Asia/Tokyoは1888年より前がLMTで、オフセットが+9:18:59。1926年から26年へ動いた瞬間にそっちの規則に入る。ICUの持っている表がちゃんと効いている、という話ではある。
暦の妥当性はUTCで見る
2月30日のような暦に無い日付をDateに渡すと、繰り上がって3月2日になる。弾くには組み立てて読み直す往復比較をする。入れた値と出てきた値が違えば繰り上がった、と判定する形。
これをローカルのDateでやると壊れる。TZ=America/New_Yorkで試した結果。
2026-02-30 02:30 -> null (正しい)
2026-03-08 02:30 -> null (実在する日付なのに弾かれる)
2026-03-08 03:30 -> 1772955000
3月8日は夏時間の開始日で、02:30という壁時計の時刻が存在しない。new Date(2026, 2, 8, 2, 30)は03:30へ送られるので、往復比較が「繰り上がった」と読む。日付そのものは暦にあるのに、時刻の都合で日付ごと落ちる。
なので2つに割った。妥当性はUTCで見て、オフセットの適用は別にやる。
const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
utc.setUTCFullYear(year)
const rolled = utc.getUTCFullYear() !== year || utc.getUTCMonth() !== month - 1 // 以下同様
if (rolled) return null
const local = new Date(year, month - 1, day, hour, minute, second)
local.setFullYear(year)
UTCには夏時間が無いので、往復比較は暦の話だけになる。存在しない02:30をローカルとして読んだときは、Dateの決めごとに従って03:30の瞬間を指す。
toUnixtime('2026-03-08T02:30:00', 'local') -> 1772955000
それを戻すと -> 2026-03-08 03:30:00
エラーにはしない。壁時計に無い時刻を入れられたときに何を返すかはブラウザのDateが決めていて、そこに自前の判断を足す理由が無かった。
範囲外はgetUTCFullYear()のNaNで拾う
Dateが持てるのは8640000000000000ミリ秒まで。秒で受け取った値を1000倍するので、入力の桁数しだいで簡単に飛び越える。
桁あふれを入力の文字数で個別に弾こうとすると面倒だが、作ったDateから年を読むだけで済む。
new Date(8640000000000000).getUTCFullYear() // 275760
new Date(8640000000000001).getUTCFullYear() // NaN
範囲外はgetTime()がNaNになり、getUTCFullYear()もNaNになる。あとは年を4桁に収める上限と一緒に見ればいい。
if (Number.isNaN(year) || Math.abs(year) > MAX_YEAR) {
return null
}
順番だけ要る。Math.abs(NaN) > 9999はfalseなので、NaNの判定を後ろに置くと範囲外がそのまま通り抜ける。
タイムゾーン名をSSRのHTMLに焼かない
Intl.DateTimeFormat().resolvedOptions().timeZoneで、動いている環境のタイムゾーン名が取れる。「ローカル(Asia/Tokyo)」というラベルに使いたくなる。
ただしAstroのislandはclient:loadでもビルド時にHTMLへ描かれる。初期表示にこの値が入っていると、ビルド機のタイムゾーンがそのまま焼き付く。react-dom/serverで確かめた。
TZ=UTC -> <p>ローカル(UTC)</p>
TZ=Asia/Tokyo -> <p>ローカル(Asia/Tokyo)</p>
TZ=America/New_York -> <p>ローカル(America/New_York)</p>
ビルド機がAsia/Tokyoなら、ニューヨークで開いた人にも最初は「ローカル(Asia/Tokyo)」と出る。hydrateされれば直るが、直るまでのあいだ嘘を表示することになる。
なので変換結果が出てから読むことにした。入力が空のあいだはその行自体が無いので、ビルドされたHTMLにタイムゾーン名は現れない。dist/tools/unixtime-converter/index.htmlをAsia/Tokyoでgrepすると0件になる。
まとめ
- Intlの
yearは紀元前でも正の数。eraを要求して1 - eraYearでISO 8601へ移す Date.UTC(26, 0, 1)は1926年。0から99はsetUTCFullYearで入れ直す- 暦の妥当性検査はUTCで。ローカルでやると夏時間の日に実在する日付を弾く
- 範囲外は
getUTCFullYear()のNaNで拾える。Number.isNaNを先に置く - タイムゾーン名はSSRのHTMLに残さない
2桁年の互換以外は、どれも仕様として筋が通っている。紀元前が正の数なのも、暦の年とeraを別のフィールドに持つ設計から見れば当たり前ではある。当たり前ではあるんだけど、501-01-01と出たときは一瞬固まった。