本文へ移動
myowchick

紀元前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(秒)erayeartoISOString()の年
-62135596800AD10001
-62167219200BC10000
-62198755200BC2-0001
-77945673600BC501-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と出たときは一瞬固まった。

← 一覧へ戻る