Skip to content

日付・時刻

日付と時刻は、一見すると単なる数値の一種に見えます。しかし「ある瞬間」を表すのか「カレンダー上の日付」を表すのかで必要な情報は異なり、そこにタイムゾーンや暦の規則が絡むと、途端に扱いが難しくなります。エンジンとしては、内部でどんな数値表現を採るか、その数値と年月日時分秒をどう相互変換するか、タイムゾーンや国際化にどこまで踏み込むかを設計しなければなりません。この章では、瞬間の表現方法とその精度のトレードオフ、暦日計算のアルゴリズムとそこに潜む符号の罠、タイムゾーンがもたらす複雑さ、そして国際化を非目標とする設計判断、最後に不変な値型による近代的な設計を扱います。

瞬間をどう表すか — エポックからのオフセット

「ある瞬間(instant)」を表すもっとも単純で堅牢な方法は、固定された基準時点(エポック)からの経過時間を一つの整数で持つことです。広く使われる基準は協定世界時(UTC)の 1970-01-01 00:00:00 で、そこからの経過を数えます。この表現の利点は、瞬間そのものにはタイムゾーンも暦も関係しないという点です。地球上のどこで観測しようと同じ瞬間は同じ数値であり、二つの瞬間の前後比較は単なる整数の大小比較になります。

問題は「どの単位で数えるか」、すなわち精度の選択です。単位を細かくすればより短い時間差を表せますが、同じ範囲を表すのに必要なビット幅が増えます。

ミリ秒精度と Number 範囲

JavaScript の古くからある Date は、エポックからのミリ秒を倍精度浮動小数点数(Number)で保持します。倍精度は仮数部が 52 ビットあり、2^53 までの整数を正確に表せます。ミリ秒単位なら、これはエポックの前後およそ 28 万 5000 年に相当し、実用上あらゆる日付を安全な整数範囲で表せます。仕様はさらに安全側に倒し、有効範囲をエポック前後それぞれ約 1 億日(±8.64×10^15 ミリ秒)に制限しています。

この方式の魅力は、値表現の枠(「値の表現」章参照)にそのまま収まることです。エンジンが既に持っている Number をそのまま流用でき、新たな整数型を用意する必要がありません。加減算も浮動小数点演算で済みます。欠点は精度で、ミリ秒より細かい時間差、たとえばマイクロ秒やナノ秒は表現できません。高分解能タイマーや近年のシステムクロックはナノ秒単位の値を返すため、ミリ秒精度ではそれを丸めて取り込むしかありません。

ナノ秒精度と広い整数

より高い精度を求めるなら、単位をナノ秒に下げます。しかしエポック前後の実用的な範囲(±約 27 万年程度)をナノ秒で数えると、必要な値は 2^53 をはるかに超え、倍精度の安全整数には収まりません。したがってナノ秒精度の瞬間は、倍精度ではなくより広い整数、すなわち 64 ビットを超える整数(たとえば任意精度の BigInt、あるいは 96〜128 ビットの固定幅整数)で保持する必要があります。

これは実装コストに直結します。BigInt を使えば範囲の心配はなくなりますが、瞬間の生成・比較・加減算のたびに任意精度演算が走り、Number の単純な浮動小数点演算に比べて重くなります。固定幅の広い整数を自前で用意すれば速度は稼げますが、桁あふれの管理や乗除算の実装が必要になります。いずれにせよ、値表現の枠には収まらないヒープ確保が伴うことが多く、ミリ秒 + Number の手軽さは失われます。

精度の比較

観点 ミリ秒 + 倍精度(Number) ナノ秒 + 広い整数
表せる分解能 ミリ秒まで ナノ秒まで
必要な数値表現 既存の Number に収まる 2^53 超、BigInt / 128ビット等が必要
実用範囲 エポック前後 約27万年(±約1億日) エポック前後 約27万年
演算コスト 浮動小数点1命令相当 任意精度/多倍長演算
ヒープ確保 不要(値の枠に載る) 伴うことが多い
高分解能クロックの取り込み 丸めが必要 そのまま保持

軽量なエンジンでは、まずミリ秒 + Number を基本とし、ナノ秒精度が要る値型(後述の Instant など)だけを広い整数で持つ、という二段構えが現実的です。精度は「常に最高」を選ぶものではなく、その値型が表すべき対象に応じて選ぶものだからです。

暦日の計算 — エポック数と年月日の相互変換

エポックからの経過を数えた一つの整数は、そのままでは人間に読めません。「エポックから 1_700_000_000_000 ミリ秒」が何年何月何日なのかを知るには、経過を年・月・日へ分解する計算が要ります。逆に、ユーザが与えた年月日から経過数を組み立てる計算も要ります。この双方向の変換が暦日計算の中核です。

まず時刻部分は単純です。1 日 = 86_400_000 ミリ秒という固定の換算で、日数部分と一日内のミリ秒に分けられます(タイムゾーンとうるう秒を考えない UTC 前提の場合。うるう秒は後述のように多くのエンジンで無視します)。難しいのは日数部分を年月日に直す、あるいはその逆を求める部分で、ここにうるう年と月ごとの日数のばらつきが絡みます。

うるう年と月の長さ

グレゴリオ暦のうるう年規則は、4 で割り切れる年はうるう年、ただし 100 で割り切れる年は平年、さらに 400 で割り切れる年はうるう年、というものです。うるう年の 2 月は 29 日、平年は 28 日です。素朴に実装すると、年をまたぐたびにうるう判定と月ごとの日数テーブルを引いてループで日数を積む形になりますが、これは範囲が広いと遅く、境界でのミスも起きやすいところです。

days-from-civil / civil-from-days

より堅牢なのは、ループを使わず閉じた式で相互変換するアルゴリズムです。days-from-civil(年月日 → エポックからの日数)と civil-from-days(日数 → 年月日)と呼ばれる変換がよく知られており、C++ の <chrono> の日付計算の基礎にもなっています。

要点は、年の始まりを 1 月ではなく 3 月とみなす座標変換にあります。こうするとうるう日(2 月末)が「年の最後」に来るため、うるう年による不規則性が年境界の外へ追い出され、月の長さのパターンが規則的な式で表せます。この工夫により、分岐の少ない四則演算だけで、任意の年月日と通算日数を相互に変換できます。アルゴリズムの正確な係数は原典に譲りますが、エンジン実装者にとって重要なのは「ループとテーブル引きに頼らず、検証済みの閉じた式を採用できる」という点です。

曜日

曜日は通算日数から容易に得られます。エポックである 1970-01-01 が木曜日であることが分かっているので、通算日数にこの基準の曜日オフセットを足し、7 で割った余りを取れば曜日が求まります。ここで注意すべきは剰余の符号で、エポックより前の日付では通算日数が負になり、素朴な剰余演算では負の曜日番号が出てしまいます。これは次の罠と同じ根を持ちます。

符号の罠(数値と精度の章の再掲)

負のタイムスタンプ、すなわちエポック(1970年)より前の日付を年月日に分解するとき、多くの言語の除算・剰余がゼロ方向への切り捨て(truncating division)であることが牙をむきます。「数値と精度」章で扱ったとおり、切り捨て除算と切り捨て剰余の組は負の被除数に対して数学的な切り下げ除算とずれます。

暦日計算では、まずミリ秒を「日数」と「一日内のミリ秒」に分けるところで割り算と剰余を使います。エポック前の時刻、たとえばエポックの 1 ミリ秒前は -1 ミリ秒です。これを 1 日 = 86_400_000 ミリ秒で割って日内ミリ秒を取り出すとき、切り捨て除算では日数が 0、切り捨て剰余では日内ミリ秒が -1 になってしまいます。しかし正しくは「エポック前日の 23:59:59.999」、すなわち日数は -1、日内ミリ秒は 86_399_999 でなければなりません。切り捨てのままだと日付が一日ずれ、時刻が負になるという二重の誤りが出ます。

正しくは、切り下げ除算(floor division)と切り下げ剰余を必ずペアで使います。

days      = floorDiv(ms, 86_400_000)          # ゼロ方向でなく負の無限大方向へ切り下げ
msOfDay   = ms - days * 86_400_000            # 常に 0..86_399_999 に収まる

同じ配慮は days-from-civil / civil-from-days の内部、および曜日を求める剰余にも一貫して要ります。切り下げ除算を使う箇所と切り捨て除算を使う箇所が混在すると、境界近傍だけで誤る発見しにくいバグになります。除算・剰余を一箇所に集約したヘルパー(切り下げ除算・切り下げ剰余)を用意し、暦日計算はそれだけを使う、という規律が有効です。この符号ペアリングの一般論は「数値と精度」章を参照してください。

タイムゾーンがもたらす複雑さ

ここまでは UTC を暗黙の前提にしてきました。瞬間そのものは UTC で一意に定まりますが、人間が使う「ローカルな日付・時刻」はタイムゾーンに依存します。同じ瞬間でも、地域が違えば表示される時計の値が違います。この「瞬間 ↔ ローカルな壁時計の時刻」の変換こそが、日付処理をもっとも複雑にする部分です。

オフセットと変換

もっとも単純なタイムゾーンは、UTC からの固定オフセットです。たとえば UTC+9 なら、瞬間に 9 時間を足したものがローカルな壁時計の時刻になります。オフセットが固定なら変換は加減算だけで、瞬間とローカル時刻は 1 対 1 に対応します。

現実の地域タイムゾーンはこれほど単純ではありません。多くの地域は夏時間(DST)を採用し、年に二回オフセットが切り替わります。さらに、ある地域が過去に採用していたオフセットや DST の規則は時代とともに変わってきました。したがって「この地域のこの瞬間のオフセット」は、地域と瞬間の両方に依存する関数になります。

DST が壊す一意性

DST の切り替えは、ローカル時刻と瞬間の 1 対 1 対応を壊します。二つの厄介な現象が生じます。

  • 存在しないローカル時刻: 春に時計が進む切り替え(たとえば 02:00 が 03:00 へ飛ぶ)では、02:00〜02:59 という壁時計の時刻がその日には存在しません。ユーザがこの範囲の時刻を指定したとき、エンジンはそれをどう解釈するかを決めなければなりません。
  • 曖昧なローカル時刻: 秋に時計が戻る切り替え(たとえば 02:00 が 01:00 へ戻る)では、01:00〜01:59 という壁時計の時刻がその日に二回現れます。ユーザがこの範囲を指定したとき、前のオフセットと後のオフセットのどちらの瞬間を指すのかが曖昧になります。
[spring forward]  01:59 --> 03:00     ( 02:00 .. 02:59 : gap )
   ... 01:58  01:59  [ missing ]  03:00  03:01 ...

[fall back]       01:59 --> 01:00     ( 01:00 .. 01:59 : x2 )
   ... 01:58  01:59  01:00* 01:59*  02:00 ...   (* = repeated)

春の切り替えでは 02:00〜02:59 が存在せず(gap)、秋の切り替えでは 01:00〜01:59 が二度現れる(overlap)。前者は解なし、後者は解が二つの多価変換になる。

つまりローカル時刻から瞬間への変換は、地域によっては「解が存在しない」か「解が二つある」場合を持つ多価関数です。エンジンはどちらかに決める方針(たとえば存在しない時刻は先送りする、曖昧な時刻は早い方を採る、など)を持たなければならず、この方針の選択自体が API 設計の一部になります。この扱いは後述の「実装上の罠」で改めて取り上げます。

名前付き IANA タイムゾーン

「この地域のこの瞬間のオフセット」を正しく求めるには、地域ごとの歴史的なオフセット変更と DST 規則の全履歴を持つデータが要ります。これを提供するのが IANA Time Zone Database(tz database)で、Asia/TokyoAmerica/New_York といった名前付きタイムゾーンごとに、過去から将来にわたるオフセットの遷移を収録しています。

このデータベースには二つの重い性質があります。第一に、大きいこと。世界中の地域の全履歴を含むため相応のサイズがあり、これをエンジンに同梱すればバイナリが膨らみます。第二に、更新され続けること。各国政府は DST の採否やオフセットを政治的判断で変更し、tz database はそのたびに更新版をリリースします。同梱したデータは時とともに古くなり、正しい結果を保つには更新機構が要ります。名前付きタイムゾーンを正しくサポートするとは、この大きく更新され続けるデータベースを抱え込むことにほかなりません。

国際化とタイムゾーンを非目標にする設計判断

タイムゾーンに続き、もう一つデータを要する領域が国際化(i18n)です。ロケールに応じた日付の書式化・解析・照合(たとえば「2026年7月19日」対 "July 19, 2026" 対 "19/07/2026"、月名・曜日名の各言語表記、暦法の違い)を正しく行うには、CLDR に代表される大規模なロケールデータが必要です。これは tz database と同様に、大きく、かつ更新され続けます。

ここで軽量志向のエンジンには一つの正当な設計判断があります。完全な国際化と名前付きタイムゾーンのサポートを非目標(non-goal)とすることです。これは特定エンジンの欠陥ではなく、フットプリントと機能範囲を天秤にかけた明示的な選択です。トレードオフを整理すると次のようになります。

観点 フル対応(i18n + 名前付き TZ) 非目標とする(UTC + 固定オフセット中心)
同梱データ tz database + CLDR 規模のロケールデータ 不要(暦計算の式のみ)
バイナリ/配布サイズ 大きい 小さい
更新運用 データ更新の追従が継続的に必要 不要
ロケール別書式 各言語・各暦法に対応 機械可読な固定形式(ISO 8601 等)のみ
名前付きタイムゾーン Asia/Tokyo 等を解決可能 UTC と数値オフセットに限る
適する用途 一般消費者向けの表示・地域依存処理 埋め込み・サーバ内計算・ログ・データ交換

非目標とすることで得られるのは、データベースを一切同梱しない小さなフットプリントと、更新運用からの解放です。手放すのは、ロケール依存の書式化・解析・照合と、名前付きタイムゾーンの解決です。ただし手放す範囲は無ではありません。UTC と、数値で与えられた固定オフセットに基づく瞬間 ↔ ローカル時刻の変換は、データベースなしで暦計算だけで行えます。また、ロケールに依存しない機械可読な入出力形式、すなわち ISO 8601 の日付時刻文字列(2026-07-19T12:34:56Z のような形式)の生成と解析は、テーブルなしで実装できます。

したがってこの設計判断は「日付を扱えなくする」ものではなく、「地域依存・言語依存の部分をエンジンの外に押し出す」ものです。データ交換・ログ・サーバ内計算といった用途では ISO 8601 と UTC で十分に機能し、ロケール別の表示が必要なアプリケーションは、その部分をホスト側やライブラリに委ねます。軽量エンジンにとっては、機能の網羅より配布サイズと保守負荷の小ささが価値を持つ場面が多く、その価値観のもとでは合理的な選択です。

不変な日付・時刻の値型

古典的な Date は、一つのミリ秒値の中に「瞬間」「ローカルな壁時計の時刻」「UTC の時刻」の三つの意味を混ぜて持ち、しかも可変(mutable)でした。この設計は、瞬間なのか壁時計なのかが曖昧になり、共有された日付オブジェクトが遠くのコードで書き換わるといった問題を生みます。近代的な設計は、これらの関心を別々の不変な値型に分け、演算は元の値を変えず新しい値を返す方針を採ります。TC39 の Temporal 提案がこの方向を体系化しており、代表的な型は次のように役割が分かれます。

表すもの タイムゾーン 典型的な内部表現
Instant タイムゾーンに依らない絶対的な瞬間 持たない エポックからのナノ秒(広い整数)
PlainDate カレンダー上の日付(年月日) 持たない 年・月・日のフィールド
PlainTime 壁時計の時刻(時分秒以下) 持たない 時・分・秒・端数のフィールド
PlainDateTime 日付 + 時刻(瞬間ではない) 持たない 上記フィールドの組
Duration 期間・時間差 持たない 年・月・日・時・分・秒…の各フィールド

要点は、瞬間(Instant)と壁時計(Plain 系)を型として分離することです。Instant はタイムゾーンを持たない絶対時刻で、比較や差分が曖昧さなく定義できます。一方 PlainDateTime は「2026-07-19 の 12:00」というカレンダー上の値で、どのタイムゾーンでの瞬間なのかはそれ単独では決まりません。両者を別の型にすることで、前節で述べた「ローカル時刻 → 瞬間」の変換が必要な地点(=タイムゾーンを掛け合わせる地点)がコード上で明示され、DST の曖昧さに向き合うべき箇所が型システムから浮かび上がります。

不変性がもたらすもの

すべての値型を不変にし、加算・減算・丸めといった演算が必ず新しい値を返すようにすると、日付値を安心して共有・キャッシュでき、遠隔の書き換えによるバグが構造的に消えます。実装上は、値がヒープ上に確保される場合でも「一度作ったら中身は変わらない」ため、GC やハッシュ化との相性もよくなります。

Duration の balancing(桁の正規化)

期間を表す Duration は、年・月・日・時・分・秒・ミリ秒…といった複数フィールドの組で持ちます。ここで正規化(balancing)という処理が要ります。たとえば「90 分」を「1 時間 30 分」に繰り上げる、あるいは逆に大きな単位を小さな単位へ展開する、といった桁合わせです。

balancing が単純でないのは、上位の桁ほど長さが一定でないためです。時・分・秒は固定の換算(1 時間 = 60 分など)で相互変換できますが、日・月・年はそうはいきません。1 日は必ずしも 24 時間ではなく(DST のある日は 23 時間や 25 時間になる)、1 月の日数は月によって違い、1 年の日数はうるう年で変わります。したがって「日以上」を含む Duration の正規化や、Duration を日付へ加算する処理は、基準となる日付とタイムゾーンを与えて初めて確定します。たとえば「1 か月後」は、起点が 1 月 31 日なら 2 月 28 日(または 29 日)へ丸める、といった暦依存の判断を伴います。

このため、Duration の加減算では大きい単位から順に適用する、月加算では月末を超えないようクランプする、といった規則を一貫させる必要があります。時・分・秒の範囲だけで完結する Duration は純粋な整数演算で正規化できますが、日以上を含む瞬間はカレンダーとの相互作用を避けられません。

丸め(rounding)

日付・時刻の演算には丸めもつきまといます。「この瞬間を分単位に丸める」「この期間を最も近い時間へ丸める」といった操作です。丸めには、対象の単位(smallest unit)と丸めモード(切り上げ・切り捨て・最近接・偶数への最近接など)、そして最近接時の同点をどちらへ倒すかの規則が要ります。ここでも、日以上の単位への丸めは長さが一定でないため、基準日付を伴わないと定義できない点に注意が要ります。丸めモードの符号の扱い(負の期間をゼロ方向へ丸めるのか負の無限大方向か)は、前述の切り下げ除算の話と同じく、境界で誤りやすいところです。

実装上の罠

エポック前の日付を切り捨て除算で分解する

繰り返しになりますが、これは日付処理でもっとも踏みやすい罠です。負のタイムスタンプ(1970年より前)を年月日に分解する際、言語の除算・剰余が切り捨て(ゼロ方向)であることを見落とすと、日付が一日ずれ、日内時刻が負になります。エポックの 1 ミリ秒前が「1969-12-31 23:59:59.999」ではなく誤った値になるのが典型です。

原因は、切り下げ除算と切り下げ剰余をペアで使わず、片方だけ、あるいは切り捨て版と混在させることにあります。対策は、暦日計算で使う除算・剰余をすべて切り下げ除算・切り下げ剰余のヘルパーに一本化し、生の /% を暦の分解に使わないこと。テストは正の日付だけでなく、必ずエポック前の日付、そして日境界・年境界のすぐ前後(23:59:59.999、1 月 1 日 00:00:00 の直前など)を含めます。正のタイムスタンプだけで検証すると、この符号バグは素通りします。詳しい符号ペアリングの理屈は「数値と精度」章にあります。

DST の存在しない/曖昧なローカル時刻

タイムゾーンを扱うなら、DST 切り替え時のローカル時刻を避けて通れません。存在しないローカル時刻(春の飛び越し)や曖昧なローカル時刻(秋の重複)を入力されたとき、方針を決めずに素朴に変換すると、黙って一時間ずれた瞬間を返したり、実装によってはクラッシュしたりします。

対策は、これらの縮退ケースに対する解決方針を API として明示的に持つことです。存在しない時刻はどちらのオフセットで先送りするか、曖昧な時刻は早い方と遅い方のどちらを採るか、あるいは呼び出し側に曖昧さを通知して選ばせるか。いずれにせよ「たまたまその瞬間の実装がどう振る舞うか」に任せず、規則として定義し、DST 切り替え日の境界時刻をテストに含めることが要ります。国際化・名前付きタイムゾーンを非目標とする設計であっても、固定オフセットしか扱わないならこの曖昧さは生じない、という点は非目標化の副次的な利点でもあります。DST の複雑さは、名前付きタイムゾーンを持ち込んだ瞬間に初めて発生します。

うるう秒を暦に持ち込む

現実の UTC にはうるう秒があり、まれに 1 分が 61 秒になります。しかし多くのエンジンは、1 日 = 86_400 秒という固定換算を前提とし、うるう秒を無視します。これは意図的な単純化で、うるう秒を厳密に扱おうとすると、うるう秒の挿入履歴という(これもまた更新され続ける)データが必要になり、しかも将来のうるう秒は事前に決まりません。1 日を固定秒数とみなす前提を全体で一貫させ、うるう秒は表現しない、と割り切るのが定石です。ここで一部の計算だけ厳密にしようとすると、換算が食い違って別の罠を生みます。

まとめ

  • 「瞬間」はエポックからのオフセット一つで堅牢に表せる。ミリ秒 + Number は既存の値表現に収まり手軽だが分解能はミリ秒まで、ナノ秒精度は 2^53 を超えるため広い整数を要し演算コストが上がる。精度は対象に応じて選ぶ。
  • エポック数と年月日の相互変換は、ループとテーブル引きより、検証済みの閉じた式(days-from-civil / civil-from-days)が堅牢。年始を 3 月とみなす座標変換でうるう日の不規則性を年境界の外へ追い出す。
  • エポック前の負のタイムスタンプを分解する際は、切り下げ除算と切り下げ剰余を必ずペアで使う。切り捨て除算・剰余を混ぜると日付が一日ずれ時刻が負になる。除算・剰余はヘルパーに一本化する。
  • タイムゾーンは瞬間とローカル時刻の対応を多価にする。DST は存在しない時刻・曖昧な時刻を生み、名前付きタイムゾーンは大きく更新され続ける IANA データベースを要する。
  • 完全な国際化と名前付きタイムゾーンを非目標とするのは、軽量エンジンにとって正当な設計判断。データベース同梱と更新運用を手放す代わりに小さなフットプリントを得る。UTC・固定オフセット・ISO 8601 はデータなしで扱える。
  • 近代的な設計は、瞬間(Instant)と壁時計(Plain 系)、期間(Duration)を別々の不変な値型に分ける。演算は新しい値を返し、Duration の正規化・日付への加算・丸めは、日以上の単位が長さ一定でないためカレンダーとの相互作用を伴う。

参考文献

  • ISO 8601 — ロケールに依存しない日付・時刻の表記形式の標準。
  • IANA Time Zone Database — 名前付きタイムゾーンごとのオフセット・DST 遷移の一次資料。
  • Temporal proposal (TC39) — 不変な日付・時刻の値型(Instant / PlainDate / PlainTime / Duration など)の設計。