🕰️

JavaScriptのDateはなぜ扱いづらいのか

2024/12/20に公開

READYFOR 2024 アドカレカバー画像

はじめに

READYFOR でプロダクトエンジニアをやっている pxfnc(ぴくすふぁんく) です。

本記事は READYFOR Advent Calendar 2024 の 19 日目 の記事です。

JavaScriptの言語仕様にも含まれているDateオブジェクトの扱いは、他の言語の日付時刻処理と比べてたいへん扱いづらいことで有名です(多分)。そのため、外部ライブラリとしてMoment.js date-fnsや、新しいものだとluxon、標準としてIntl.DateTimeFormat や、日付時刻を扱うためのTemporalのプロポーザルもある状態で、みんなかなり苦しんでいるような状況かと思います。

このように現時点でも多くのソリューションがありますが、そもそもDateは何なのか、どうして扱いづらいのか、という点をJavaScript初心者の方や、バックエンドエンジニアの方に知っていただくために、この記事を書くことにしました。

JavaScriptのDateオブジェクトが表すもの

MDNのドキュメントでは下記のような説明があります。

JavaScript の Date オブジェクトは、単一の瞬間の時刻をプラットフォームに依存しない形式で表します。Date オブジェクトは、1970 年 1 月 1 日午前 0 時 (UTC)(元期)からのミリ秒を表す整数値をカプセル化しています。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date

JavaScriptのDateオブジェクトは、元期からの経過時間を表す整数値のみを保持しているところが特徴です。(「元期からの経過時間」という表現はこの後頻出します)

Dateが所持している「元期からの経過時間」を取得するには、valueOfメソッドやgetTimeメソッドを使います。

const date = new Date(); // システムの現在時刻をもとに、Dateインスタンスを作成

// valueOfとgetTimeは同じ結果を返す
console.log(date.valueOf()); // 1734612916040
console.log(date.getTime()); // 1734612916040

// UTCにおける1970年1月1日からの元期からの経過時間自体を直接指定してDateインスタンスを作成することもできる
const epoch = new Date(0);
console.log(epoch.toISOString()); // 1970-01-01T00:00:00.000Z

JavaScriptのDateが他言語と大きく異なり扱いづらい箇所は、Dateのインスタンスは、タイムゾーンの情報を持つことができないという部分にあります。

この「タイムゾーンの情報を持っていない」という性質が、Dateから日時表現を取得したり、日時表現からDateを作成する際の予想外の挙動につながります。

Dateから日付を取り出す際の挙動

Dateのインスタンスタンスから日時を取り出す際、getFullYeargetDate()toLocaleString()などのメソッドを使います。この際、Dateが内部に保持する元期からの経過時間と実行環境のタイムゾーン情報の二つを用いて日時が算出されます。

つまり、Dateの内部に保持されている元期からの経過時間が同じであっても、実行環境のタイムゾーンによって取り出される日時は変わります。

// ブラウザのタイムゾーンが Asia/Tokyo の場合
console.log(new Date(0).toLocaleString()); // 1970/1/1 9:00:00
// ブラウザのタイムゾーンが America/Los_Angeles の場合
console.log(new Date(0).toLocaleString()); // 1969/12/31 16:00:00

この挙動は、Dateが内部に保持する元期からの経過時間を元に、実行環境のタイムゾーン情報を使って日時を算出しているためです。

一方で、getUTCFullYeargetUTCDategetUTCHoursたちのgetUTC*達は、実行環境のタイムゾーンを使わずに常にUTCの日時を取得できるため、環境に左右されずに常に同じ日時を取得できます。

ISO 8601等の文字列からDateに格納する際の挙動

Dateのインスタンスを作成する際に、ISO 8601の文字列を使うことができます。ここは、Dateが元期からの経過時間のみ保持するという性質を知らない方にとって、かなりギャップのある挙動をする箇所です。

日付日時の表記やオフセットという情報は「元期からの経過時間」の算出にのみ使われ、Dateに格納されることはない

日付表現からDateインスタンスを作成する際、日付表現に含まれるタイムゾーンやオフセットの情報はDateに格納されることはありません。元期からの経過時間を求め終わったら、日付表記やオフセットのことは忘れてDateインスタンスに格納し、その情報はそのまま捨ててしまいます。

const tokyo = new Date("2025-01-01T00:00:00+09:00"); // 東京の2025年1月1日 00:00:00
const los_angeles = new Date("2024-12-31T07:00:00-08:00"); // ロサンゼルスの2024年12月31日 7:00:00

// UTCにおける「元期からの経過時間」どちらも同じ
tokyo.valueOf() === los_angeles.valueOf(); // true

// どちらも同じ「元期からの経過時間」を持っているため、getホニャララは全て一致する
tokyo.toLocaleString() === los_angeles.toLocaleString();

仮に、バックエンド実装の際に「フロントでは日本時間で表示して欲しいから+09:00のオフセットつけて返却しよう」といった実装をしても、Dateに変換された瞬間にオフセットの情報は消えてしまうため、無意味になってしまいます。

しかし、オフセット付きの文字列は、「元期からの経過時間」を算出する際に、実行環境のタイムゾーンに依存することなく正確に「元期からの経過時間」を算出する際に役に立ちます。仮にオフセットがない場合、下記の問題が発生するようになります。

同じ文字列でも、実行環境のタイムゾーンによって、Dateに格納される「元期からの経過時間」が異なる場合がある

タイムゾーンやオフセットの指定がない場合、「元期からの経過時間」を求める際に、実行環境のタイムゾーンの情報が利用されます。その結果、同じ日付表現でも、Dateに格納される「元期からの経過時間」が実行環境のタイムゾーンの影響を受け、異なる値になることがあります。

// ブラウザのタイムゾーンが Asia/Tokyo の場合
const tokyo = new Date("2025-01-01T00:00:00").toLocaleString();
console.log(tokyo.toLocaleString()); // 2025/1/1 0:00:00 ← 東京の0時
console.log(tokyo.toISOString()); // 2024-12-31T15:00:00.000Z ← UTCでは前の日の15時
console.log(tokyo.valueOf()); // 1735657200000 ← 実行環境上で0時と表示されるように調整された元期からの経過時間
// ブラウザのタイムゾーンが America/Los_Angeles の場合
const los_angeles = new Date("2025-01-01T00:00:00");
console.log(los_angeles.toLocaleString()); // 2025/1/1 0:00:00 ← ロサンゼルスの0時
console.log(los_angeles.toISOString()); // 2025-01-01T08:00:00.000Z ← UTCでは8時
console.log(los_angeles.valueOf()); // 1735718400000 ← 実行環境上で0時と表示されるように調整された元期からの経過時間

パースする文字列にタイムゾーン(オフセット)情報がない場合、その文字列が示す日時を取得できるような「元期からの経過時間」をDateに格納しようとします。そのために、実行環境のタイムゾーンの情報を用いてUTCとの差分を調整します。

ここが危険な部分です。同じ文字列であってもパース処理が実行環境のタイムゾーンの影響を受け、環境ごとに異なる「元期からの経過時間」を算出してしまいます。 その結果、Dateを用いた計算や比較が環境によって異なる結果を返すようになってしまいます。

例えば、何らかの開始時刻と終了時刻の差分を測って経過時間を算出するロジックを作るとします。その際、date.valueOf()で「元期からの経過時間」同士を引くこと経過時間を求めることができます。

しかし、同じ文字列、同じ計算式であるにもかかわらず、実行環境のタイムゾーンによってはサマータイムの影響を受けて、正しい時間の計算ができなくなってしまいます。

// ブラウザのタイムゾーンが Asia/Tokyo の場合
const start = new Date("2024-11-03T01:00:00");
const end = new Date("2024-11-03T02:00:00");
console.log(end.valueOf() - start.valueOf()); // 3600000 一時間の差。
// ブラウザのタイムゾーンが America/Los_Angeles の場合
const start = new Date("2024-11-03T01:00:00");
const end = new Date("2024-11-03T02:00:00");
console.log(end.valueOf() - start.valueOf()); // 7200000 サマータイムが終了し、時計を一時間巻き戻すので、1時から2時までは二時間かかる。

ただし、同じ文字列であってもパース処理が実行環境のタイムゾーンの影響を受ないパターンもある

しかし、例外パターンがあり、"2025-01-01"のような日付のみの表記に限っては常にUTCとして解釈されるため、同じ入力であれば、実行環境に限らず、同じ「元期からの経過時間」になります (こういう例外の例外って教習所みたいだな)

// ブラウザのタイムゾーンが Asia/Tokyo の場合
console.log(new Date("2025-01-01").valueOf()); // 1735689600000
console.log(new Date("2025-01-01").toISOString()); // 2025-01-01T00:00:00.000Z
// ブラウザのタイムゾーンが America/Los_Angeles の場合
console.log(new Date("2025-01-01").valueOf()); // 1735689600000
console.log(new Date("2025-01-01").toISOString()); // 2025-01-01T00:00:00.000Z

まとめ

JavaScriptのDateは、絶対時刻に対する日付処理に必要なタイムゾーン情報を、Dateではなく実行環境のタイムゾーンが持っている作りになっています。 そのため、Date相当のデータ型がタイムゾーン情報を保持できることによって容易にできるはずの操作が軒並みできません。

  • Dateに対する日付操作を、プログラムが指定するタイムゾーン上で行う
  • 絶対的な時間とタイムゾーンの組をプログラム内で引き回す
  • タイムゾーン付きの日時とタイムゾーン無しの日時を区別する
  • 日付時刻書式指定子を用いたフォーマット

これらの問題を解決するために、冒頭で紹介した外部のライブラリや標準化されたAPI達が存在しています。ライブラリによっては独自の日付型のデータ構造を用意したり、Dateはそのままで、加工のためのヘルパー関数群をたくさん用意したり等、アプローチは様々です。

この記事が、JavaScriptのDateがなぜ扱いづらいのかを深く理解できるきっかけになれば幸いです!

READYFORテックブログ

Discussion