Temporal APIと日時計算の正確なモデル
Dateのバグに振り回される日々から抜け出せる。可変・タイムゾーン非対応というDateの構造的欠陥と、不変オブジェクト・暦/タイムゾーン明示・PlainとZonedの分離で正しさを設計するTemporalの考え方を原理から整理します。
- 1.JavaScriptのDateは可変・月が0始まり・パースが実装依存・内部はUTCミリ秒の1値のみという設計上の欠陥を抱え、ローカル時刻とUTCの境界でバグを生みやすい。
- 2.Temporalは全オブジェクトが不変(immutable)で、タイムゾーンを持たないPlain系(壁掛け時計の時刻)と、特定地点の物理的瞬間を表すZonedDateTimeを型として分離する。
- 3.夏時間の切替やうるう秒のような不規則さは、PlainとZonedの変換時に暦・タイムゾーン規則を明示的に適用して解決する。Instantは連続したエポック秒で、うるう秒を持たない。
なぜ日時APIに「正確なモデル」が要るのか
日時のバグは「たまにずれる」形で表面化するため、再現も発見も難しいのが厄介です。月初の集計が1日ずれる、夏時間の切替日だけ予約が二重になる、サーバーとブラウザで同じ「3月1日」が別の瞬間を指す——これらは個別のコーディングミスというより、時刻を1つの数値で雑に表すという旧来モデルの帰結です。Temporalは「壁掛け時計の時刻」と「物理的な瞬間」を別物として型で区別し、暦やタイムゾーンの規則を明示的に適用させることで、これらを構造的に防ぎます。前提として基礎は JavaScript を、時刻が内部で数値として持たれる点は JavaScriptの数値表現とIEEE754が生む落とし穴 を押さえておくと理解が早いです。
Date の設計上の欠陥
Date は1995年のJavaScript誕生時にJavaの旧 java.util.Date を写して作られ、その欠陥ごと引き継ぎました。中心的な問題は次の通りです。
| 欠陥 | 具体例 | なぜ問題か |
|---|---|---|
| 可変(mutable) | d.setMonth(5) が元オブジェクトを破壊 | 共有された Date を渡すと予期せず書き換わる |
| 月が0始まり | new Date(2026, 0, 1) が1月1日 | 日・年は1始まりなのに月だけ0始まりで混乱 |
| パースが実装依存 | new Date('2026-03-01') の解釈がブラウザ間で割れる | ISO以外の文字列で結果が再現しない |
| 内部はUTCミリ秒1値のみ | タイムゾーンを保持できない | ローカル時刻とUTCの境界で必ず変換が要る |
Date の実体はエポック(1970-01-01T00:00:00 UTC)からの経過ミリ秒という1つの数値だけです。タイムゾーン情報を内部に持たず、getHours() のような「ローカル系」のメソッドは呼び出した実行環境のタイムゾーンを使って毎回その場で換算します。つまり同じ Date 値でも、東京のサーバーとロンドンのブラウザでは getDate() が別の日を返し得ます。
Date には「N日後」を新しい値として返すメソッドがなく、setDate(d.getDate() + 7) のように自身を破壊して進めるしかありません。引数で受け取った Date をうっかり加工すると呼び出し元の値まで変わります。等価判定も d1 === d2 は参照比較で常に false、d1 == d2 も期待通り動かないため d1.getTime() === d2.getTime() を強いられます。この型変換の罠は 型変換と等価比較の落とし穴 と同根です。
Temporal の中核:不変オブジェクトと型の分離
Temporalはこれらを根本から作り直した提案です。設計原則は2つに集約できます。
- 全オブジェクトが不変(immutable):
addwithroundなどはすべて元を変えず新しいインスタンスを返す。共有しても安全で、加工の副作用が消えます。 - 「時刻の種類」を型で分ける:タイムゾーンを持たない「壁掛け時計の時刻」(Plain系)と、地球上の特定の瞬間(Instant / ZonedDateTime)を別の型として区別します。
この型分離が本質です。たとえば「毎朝9時に通知」の9時は、どのタイムゾーンでも9時であってほしい壁掛け時計の時刻=Plain です。一方「打ち上げは協定世界時の正確なこの瞬間」は地球上で一意な物理的瞬間=Instant です。両者を1つの Date で兼ねていたことが、旧来の混乱の元でした。
| 型 | 持つ情報 | 代表的な用途 |
|---|---|---|
| Temporal.Instant | エポックからの連続した時刻(ナノ秒精度) | ログのタイムスタンプ、一意な瞬間の記録 |
| Temporal.PlainDate | 年月日のみ(暦付き、TZなし) | 誕生日、請求の締め日 |
| Temporal.PlainTime | 時分秒のみ(日付・TZなし) | 営業開始時刻 09:00 |
| Temporal.PlainDateTime | 年月日+時分秒(TZなし) | TZ未確定の予定の素材 |
| Temporal.ZonedDateTime | 瞬間+タイムゾーン+暦の全部入り | 会議予約、地域に紐づく日時 |
// すべて不変。元のインスタンスは変わらず、新しい値が返る
const d = Temporal.PlainDate.from('2026-03-01'); // 月は1始まり
const next = d.add({ days: 7 }); // 2026-03-08(dはそのまま)
// 物理的な瞬間。タイムゾーンに紐づけて初めて「壁掛け時刻」になる
const meeting = Temporal.ZonedDateTime.from(
'2026-03-01T09:00[Asia/Tokyo]'
);
meeting.toInstant().epochNanoseconds; // 全世界で一意な瞬間
PlainDateTime は「3月1日9時」までは決めるがタイムゾーンを欠くため、まだ物理的瞬間ではありません。これに Asia/Tokyo のようなタイムゾーンを与えて初めて ZonedDateTime(=一意な Instant)が確定します。Plain と Zoned の往復で必ずタイムゾーンを通すこの設計が、後述の夏時間問題を表に引きずり出します。
Plain と Zoned の境界:夏時間(DST)をどう扱うか
夏時間(DST)の切替日には、存在しない時刻と二度ある時刻が生まれます。たとえば米国で春に時計が午前2時から3時へ飛ぶ日、02:30 は実在しません。秋に3時から2時へ戻る日、01:30 は1日に2回訪れます。Date はこの不規則さを暗黙に握りつぶし、どちらの瞬間を指すか不定でした。
Temporalは PlainDateTime(壁掛け時刻)から ZonedDateTime(瞬間)へ変換する瞬間に、この曖昧さを明示的な選択肢として扱わせます。
| disambiguation | 存在しない時刻 | 二度ある時刻 |
|---|---|---|
| 'compatible'(既定) | 後ろ側(gap後)の瞬間を採用 | 前側(最初の出現)を採用 |
| 'earlier' | gap直前の瞬間 | 最初の出現 |
| 'later' | gap直後の瞬間 | 2回目の出現 |
| 'reject' | RangeError を投げる | RangeError を投げる |
// DSTで存在しない壁掛け時刻を瞬間へ写すとき、挙動を選べる
const pdt = Temporal.PlainDateTime.from('2026-03-08T02:30');
pdt.toZonedDateTime('America/New_York', { disambiguation: 'reject' });
// → RangeError(存在しない時刻を握りつぶさず明示的に弾く)
重要なのは、ZonedDateTime 同士の「N日後」と「N時間後」が別物になる点です。add({ hours: 24 }) は物理的に24時間後の瞬間へ進めるのに対し、add({ days: 1 }) は暦の上で翌日の同じ壁掛け時刻へ進めます。DST切替を跨ぐ日、後者は実際の経過が23時間や25時間になり得ます。Date ではこの区別が表現できず、「日付の加算」を時間ミリ秒で代用したコードが切替日だけ1時間ずれていました。
夏時間の開始・終了日や各地のオフセットは法律で頻繁に変わるため、ハードコードしてはいけません。Temporalは America/New_York のようなIANAタイムゾーン識別子を使い、実行環境が持つIANA Time Zone Databaseの規則を参照します。+09:00 のような固定オフセットも指定できますが、それはDSTの切替規則を持たない「ただのずれ」であって、地域の時刻ルールとは別物です。識別子(地域名)とオフセット(数値のずれ)を混同しないことが頻出ポイントです。
暦(カレンダー)とうるう秒の正しい位置づけ
ZonedDateTime と PlainDate は**暦(calendar)**も保持します。既定は iso8601(グレゴリオ暦の代理)ですが、japanese(和暦)や hebrew、islamic なども扱え、year month のような数値が暦ごとに正しく解釈されます。たとえば年内の月数が12でない暦では、add({ months: 1 }) の意味が暦規則に従って決まります。これも「数値の足し算」では表せなかった情報です。
うるう秒の扱いは特に誤解されがちです。Temporalの Instant と内部時刻はうるう秒を持たない連続したタイムライン(UTCのうるう秒を平滑化したPOSIX的なエポック時刻)で表されます。つまり 23:59:60 のような値は受け付けず、60 秒は存在しないものとして丸められます。
うるう秒は地球の自転のゆらぎに合わせて不定期に挿入される1秒で、未来の挿入予定を事前に確定できません。これをそのまま時刻計算に持ち込むと、過去の任意の2瞬間の差さえ将来の挿入で変わりかねず、計算が不安定になります。そこでTemporalは多くのOSやデータベースと同様、うるう秒を平滑化した連続軸を採用し、厳密な物理時刻が要る用途(天文・測位など)は専用系に委ねる、という割り切りをしています。連続したエポック値という考え方は JavaScriptの数値表現とIEEE754が生む落とし穴 の整数精度とも関わり、Instant は内部でナノ秒精度を扱うため BigInt で epochNanoseconds を公開します。
永続化と相互運用
不変で型が分かれている分、保存・送信時の形式選びも明確になります。物理的な瞬間を保存するなら、タイムゾーンまで含む ZonedDateTime のISO文字列(...[Asia/Tokyo] 付き)か、地点情報が不要なら Instant(UTCの Z 付き)を使います。一方、壁掛け時刻そのもの(毎朝9時など)はタイムゾーンを付けずに PlainTime / PlainDate として保存するのが正解で、ここに無理にタイムゾーンを与えると「どこかの9時」に固定されて意図がずれます。
// 瞬間として保存(地点も復元したいなら ZonedDateTime 文字列)
zdt.toString(); // "2026-03-01T09:00:00+09:00[Asia/Tokyo]"
zdt.toInstant().toString(); // "2026-03-01T00:00:00Z"(UTCの瞬間のみ)
Temporalオブジェクトは構造化クローンの対象にもなり得ますが、ワーカー間やストレージへ渡す際は文字列化してから扱うのが堅実です(クローンの可否や落とし穴は 構造化クローンとTransferable を参照)。Date との橋渡しは Temporal.Instant.fromEpochMilliseconds(date.getTime()) などで瞬間を介して行い、ローカル時刻のメソッドを経由しないのが安全です。
まとめ
Date は可変・月0始まり・パース実装依存・内部はUTCミリ秒の1値という構造的欠陥を抱え、壁掛け時刻と物理的瞬間を1つの数値に押し込めたことが日時バグの温床でした。Temporalはこれを全オブジェクト不変と型の分離で作り直し、タイムゾーンを持たない Plain 系と、特定の瞬間を表す Instant / ZonedDateTime を別物として扱います。両者の変換時にタイムゾーンを必ず通すため、夏時間の存在しない時刻・二度ある時刻は disambiguation の明示的な選択として表面化し、暗黙の握りつぶしが消えます。タイムゾーン規則はIANA TZ DBが正、暦は明示でき、Instant はうるう秒を持たない連続軸です。「その時刻は壁掛けか、瞬間か」を最初に決める習慣が、再現しにくい日時バグを設計段階で断ちます。
Web/フロントエンド Article
Temporal APIと日時計算の正確なモデルを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
Temporalは全オブジェクトが不変(immutable)で、タイムゾーンを持たないPlain系(壁掛け時計の時刻)と、特定地点の物理的瞬間を表すZonedDateTimeを型として分離する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「JavaScript / Temporal」に近いか確認する。
- 強みである「JavaScriptのDateは可変・月が0始まり・パースが実装依存・内部はUTCミリ秒の1値のみという設計上の欠陥を抱え、ローカル時刻とUTCの境界でバグを生みやすい。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。