かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

JavaScript Date オブジェクトで紀元前の日付を扱いたい

歴史を扱うプロジェクトに参加しています。
歴史の話なのでフロントエンドで紀元前の日付を扱ったり比較をする必要があり、マイナス年も Date で扱うための Tips

前提とゴール

  • 1582年10月4日(木) までがユリウス暦で翌日がグレゴリオ暦の1582年10月15日(金)なので、厳密な曜日などは異なるがあくまで日付を比較できるように Date オブジェクトで扱えることをゴールとする
  • 入力される日付がユリウス暦かグレグリオ暦かは気にしない
  • 0000/01/01 を西暦 0年 = 紀元前 1年、 -0001/01/01 を西暦 -1年 = 紀元前 2年と捉える ※ 西暦0年は存在しないため
  • 閏年の運用誤差をカバーしない
    • 紀元前45年にカエサルがこの暦法を導入した際に閏年は4年に1回と決められたが、直後の紀元前44年にカエサルが暗殺された後、誤って3年ごとに1回ずつ閏日が挿入された。この誤りを修正するため、ローマ皇帝アウグストゥスは、紀元前6年から紀元後7年までの13年間にわたって、3回分(紀元前5年、紀元前1年、紀元4年)の閏年を停止した。紀元8年からは正しく4年ごとに閏日を挿入している。
      cf. ユリウス暦 - Wikipedia

    • つまり紀元8年以前の2月29日は存在が正しいか正しくないか分からない。Date オブジェクトに換算した時に存在しない扱いになると翌 3月1日に変換されてしまうので、同年の3月1日との比較が正しく行えないが、これだけ古い時代を日付単位で比較するケースは殆無いので誤差を考慮しない

日付ムズい…

JavaScript の Date の仕様

Date の扱える範囲

A JavaScript date is fundamentally specified as the time in milliseconds that has elapsed since the epoch, which is defined as the midnight at the beginning of January 1, 1970, UTC (equivalent to the UNIX epoch). This timestamp is timezone-agnostic and uniquely defines an instant in history.
The maximum timestamp representable by a Date object is slightly smaller than the maximum safe integer (Number.MAX_SAFE_INTEGER, which is 9,007,199,254,740,991). A Date object can represent a maximum of ±8,640,000,000,000,000 milliseconds, or ±100,000,000 (one hundred million) days, relative to the epoch. This the range from April 20, 271821 BC to September 13, 275760 AD. Any attempt to represent a time outside this range results in the Date object holding a timestamp value of NaN, which is an "Invalid Date".
cf. Date - JavaScript | MDN

 

まず、Dateオブジェクトは有限の範囲の日付しか扱えません。 この範囲は、UNIX時間にNumber.MAX_SAFE_INTEGER(2 ** 53 - 1)ミリ秒足したものではない事に注意してください。 ECMA-262 は、Dateオブジェクトで表すことができる時刻の範囲はエポックから前後 ±100,000,000 (1億) 日、紀元前271821年4月20日から紀元275760年9月13日)と定義しています[2]
また、現行の協定世界時 (UTC) において、世界時のUT1との差を調整するため、閏秒(うるうびょう)が挿入されますが、ECMA標準では挿入されない仕様 となっています。そのため、秒単位では違いがある可能性があります。
cf. JavaScript/Date - Wikibooks

new Date('1582-10-04T12:00:00')
// Mon Oct 04 1582 12:00:00

ユリウス暦 1582-10-04 は木曜だが、グレグリオ暦 1582-10-15 (金) から日付を引いた曜日 = 月曜 になっている
JavaScript の Date オブジェクトで表せられる曜日はグレゴリオ暦に切り替わる前ではズレが生じている

new Date('YYYY-MM-DD') はマイナス年の時、実装によって解釈が異なる結果になる

日付文字列を解釈する際には、常に入力が ISO 8601 形式 (YYYY-MM-DDTHH:mm:ss.sssZ) であることを確認してください。他の形式で解釈した場合には、その挙動は実装によって定義されていて、すべてのブラウザーで動くとは限りません。
cf. Date() コンストラクター - JavaScript | MDN

マイナス年を表そうとすると -0001-01-01 のような形になるので ISO 8601 形式 違反となるため実装によって挙動が変わってしまう

// 0, 正の年なら問題ない
new Date('1582-10-04')
// Mon Oct 04 1582
new Date('0000-01-01')
// Sat Jan 01 0000

// 負の年は ISO 8601 形式 違反 の為 挙動が実装によって異なる
new Date('-0001-01-01')
// Mon Jan 01 2001 00:00:00
new Date('-0100-01-01')
// Fri Jan 01 0100 00:00:00
new Date('-0099/01/01')
// Fri Jan 01 1999 00:00:00
new Date('-0002/01/01')
// Thu Feb 01 2001 00:00:00

マイナス年を使用するとどう変換されるか予測がつかない

new Date(year, monthIndex, day)year が 0 - 99 の時、1900 - 1999 にマッピングされる

new Date(year, monthIndex, day)
year
年を表す整数値です。 0 から 99 までの値は、 1900 から 1999 までの値にマッピングされます。他の値は実際の年になります。
cf. Date() コンストラクター - JavaScript | MDN

new Date(0, 0, 1)
// Mon Jan 01 1900 00:00:00
new Date(99, 0, 1)
// Fri Jan 01 1999 00:00:00
new Date(100, 0, 1)
// Fri Jan 01 0100 00:00:00

マイナスの値は動作する

new Date(-1, 0, 1)
// Fri Jan 01 -0001 00:00:00
new Date(-99, 0, 1)
// Tue Jan 01 -0099 00:00:00

マイナスの値は動作するが、西暦 0 年 (紀元前 1年) が 1900 年にマッピングされてしまう問題がある

ざっくりとしたまとめ

  • 1970-01-01 を起点にして何ミリ秒経過しているかから計算している
    • つまり 1582-10-15 以前がユリウス暦に変換されるわけではない
  • 閏秒のズレがあるので秒単位では性格ではない可能性がある
  • マイナス年の時 new Date(YYYY-MM-DD) 形式は使えない
  • new Date(year, monthIndex, day) の時 year が 0 - 99 の時 1900 - 1999 年に自動で変換されてしまう

date.setFullYear(yearValue) で年だけ後から追加する

setFullYear() メソッドは、地方時に基づき、指定された日付の「年」を設定します。新しいタイムスタンプを返します。
cf. Date.prototype.setFullYear() - JavaScript | MDN

手順 1. 月, 日だけの Date オブジェクトを作成する 2. date.setFullYear(yearValue) で必要な年を指定する

const getDate = (year: number, month: number, day: number): Date => {
  const d = new Date(0, month - 1, day);
  return new Date(d.setFullYear(year));
}

👇

getDate(1, 1, 1);
// Mon Jan 01 0001
getDate(0, 1, 1);
// Sat Jan 01 0000
getDate(-100, 1, 1);
// Mon Jan 01 -0100
getDate(1938, 11, 30);
// Wed Nov 30 1938

少し手間ですが、setFullYear を使うことで 紀元前を含めた日付を Date オブジェクトで扱えるようになりました!
Date オブジェクトにさえすれば date-fns などで比較は簡単にできるので取り回しが楽になります

おわり


[参考]