乗りこなせ! モダンフロントエンド

Temporalで変わるJavaScriptの日時操作 [JS Modern Features no.1]

本連載について

はじめまして! サイボウズ フロントエンドエキスパートチームの左治木です。

本連載では、Webフロントエンドに関してもう一歩踏み込んだ知識について、サイボウズフロントエンドエキスパートチームのメンバーによって不定期で解説記事を掲載しています。前回までの記事では「CSS Modern Features」と題して、CSSの比較的新しい機能の中でインパクトの大きいものをピックアップして弊チームの麦島が解説してくれました。

JS Modern Features / JavaScriptの進歩

この記事からは「JS Modern Features」と題して、JavaScriptに関するコンテンツを数回に分けてお届けします。

JavaScriptはWebの中核技術としてはもちろん、サーバサイドやモバイル、CLIツールの開発でも広く使われている言語です。JavaScriptの標準仕様は「ECMAScript」として2015年より前までは数年おきに、2015年以降は毎年新しい仕様が公開され続けており、その度に新しい機能や構文が追加されています。またブラウザを基本とする多くのランタイムは早い段階でこれらの新しい仕様に追従しており、ブラウザであれば仕様が策定されてから2、3年以内にはほぼすべての新しい機能が使える状態になっています。

この「JS Modern Features」では、JavaScriptの比較的新しい機能から数年内に新たに使える見込みの高い機能まで、インパクトが大きい機能をいくつかピックアップして紹介します。

初回となる今回は「Temporalで変わるJavaScriptの日時操作」と題して、JavaScriptの新しい日時操作APIとして提案されているTemporalについて解説します。

新しい日時操作の仕様⁠Temporal

JavaScriptを利用している方の多くはJavaScriptで日時を扱ったことがあると思います。JavaScriptでは標準でDateオブジェクトが存在し、日時データの保持や簡単な日付の計算・書式化が行えるようになっています。

一方でこのDateオブジェクトはタイムゾーンサポートが足りなかったり、暗黙的な挙動が多かったりといった問題点を抱えていることも知られています。そのため代わりにday.jsdate-fnsluxonなどといった日時ライブラリを利用している方も多いでしょう。

このような中、Dateオブジェクトに替わる新しい日時操作オブジェクトの仕様としてTemporalと呼ばれる組込みAPIがECMAScriptに提案されています。Temporalは現在Stage3の提案ですが、2024年からいくつかのランタイムやブラウザで試験的なサポートが始まるなど少しずつ試せる環境が増えてきている仕様です。

今回はこのTemporalについて、そのコンセプトや基本的な使い方、Intlとの関連、サポート状況などを紹介します。

既存のDateに対する不満

そもそもTemporalが提案された背景には、Dateオブジェクトに対する問題点や不満があります。これは具体的には以下のようなものです。

  1. ユーザーのシステムタイムゾーンとUTC以外のタイムゾーンはサポートされない
  2. 非グレゴリオ暦がサポートされていない
  3. 日時文字列のパース動作の信頼性が低い
  4. 日付オブジェクトの可変性の問題
  5. 日時計算のAPIが扱いにくい

タイムゾーンとカレンダーのサポート問題

1と2に関しては書いてあるとおりで、DateオブジェクトはシステムのタイムゾーンとUTCしかサポートしておらず、グレゴリオ暦以外のローカルな暦にも対応していません。

日時文字列のパース問題

3はnew Date()で日時文字列を渡した場合、どう解釈されるかが分かりにくいという問題です。たとえばnew Date('2025-01-01')とした場合、これはUTCの2025年1月1日0時0分0秒として解釈されます。しかし時刻まで含めたnew Date('2025-01-01T00:00:00')とした場合、これはシステムのタイムゾーンでの2025年1月1日0時0分0秒として解釈されます。これは、タイムゾーンオフセットやZ(タイムゾーンオフセットが存在しないことを意味する)が省略されている場合の日時文字列はシステムのタイムゾーンで解釈されるという仕様があるためです。

// システムのタイムゾーンがUTC+9の場合
new Date("2025-01-01"); // Wed Jan 01 2025 09:00:00 GMT+0900 (日本標準時)
new Date("2025-01-01T00:00:00"); // Wed Jan 01 2025 00:00:00 GMT+0900 (日本標準時)

また、ECMAScript仕様書としては書かれていないものですが、多くのランタイムでは"Wed Jan 01 2025"のようなRFC 2822形式の日時文字列や"2025/01/01"のようなスラッシュ区切りの日時文字列も解釈されます。これらの挙動はランタイムによって異なる可能性もあるため余計に混乱を招く原因となります。

// Chrome かつ システムのタイムゾーンがUTC+9の場合
new Date("Wed Jan 01 2025"); // Wed Jan 01 2025 00:00:00 GMT+0900 (日本標準時)
new Date("2025/01/01"); // Wed Jan 01 2025 00:00:00 GMT+0900 (日本標準時)

Date オブジェクトの可変性の問題

Dateオブジェクトのset系のメソッドはそのオブジェクトが持つ日時を破壊的に変更します。この挙動はset系のメソッドを日時の計算に使う場合、ミスを招く原因となります。たとえば以下のようなコードはオブジェクトの破壊的な変更を意識できていないコード例です。

const addOneWeek = (date) => {
  date.setDate(date.getDate() + 7);
  return date;
};

const today = new Date("2025-01-01"); //
const nextWeek = addOneWeek(today);

console.log(
  `Today is ${today.toLocaleString()}, and one week from today will be ${nextWeek.toLocaleString()}`
);
// Today is 2025/1/8 9:00:00, and one week from today will be 2025/1/8 9:00:00
// todayを破壊的に変更してしまったため、nextWeekもtodayが同じ日時を指してしまっている

このような意図しない破壊的変更を防ぐためにはDateオブジェクトのset系のメソッドが新しいDateのインスタンスを返すようにするか、Dateオブジェクトの値自体を不変にすることが必要です。しかしながら既存の多くのコードはDateオブジェクトの破壊的な変更挙動に依存してしまっており、これらの挙動を変えることは困難です。

日時計算のAPIが扱いにくい問題

現在のDateオブジェクトには日時ライブラリで標準的にあるような日時の加算減算・比較といった日時計算APIが存在していません。現状これらの計算をDateオブジェクトで実現するには、上記でも問題に挙げたsetメソッドを駆使したり、Dateオブジェクトを一度ミリ秒に変換して計算したりする必要があります。

Temporalとそのコンセプト

このようにJavaScriptのDateオブジェクトには多くの問題点があり、Temporalはこれらの問題を解決することを目指しています。具体的には以下のような機能を追加することで上記の問題を解決します。

  • Wall-Clock TimeとExact Timeの明確な分離
  • タイムゾーンとカレンダーのサポート
  • 豊富な計算API
  • 明確な日時文字列(タイムスタンプ)の定義とパース処理

Wall-Clock TimeとExact Timeの明確な分離

Temporalでは日時を表すデータ(クラス)「世界中でユニークな瞬間を表現するデータ」「タイムゾーン情報を持たないローカルな日付/時刻のデータ」の2つで明確に区別しています。Temporalのドキュメントなどではこれらをそれぞれ「Exact Time」「Wall-Clock Time」と呼んでいます。

具体的にはそれぞれ以下の日時データを表すクラスが「Exact Time」「Wall-Clock Time」に分類されています。

  • ExactTime
    • Temporal.Instant : タイムスタンプデータ
    • Temporal.ZonedDateTime : タイムゾーンと日時がペアになったデータ
  • WallClockTime
    • Temporal.PlainDateTime : タイムゾーンを持たない日時データ
    • Temporal.PlainDate : タイムゾーンを持たない日付データ
    • Temporal.PlainYearMonth : タイムゾーンを持たない年月データ
    • Temporal.PlainMonthDay : タイムゾーンを持たない月日データ
    • Temporal.PlainTime : タイムゾーンを持たない時刻データ

このようにWall-Clock TimeのClassは必ずPlainで始まるので一目で区別がつくようなAPIになっているのも特徴です。

またWall-Clock Timeのデータは「タイムゾーン情報を持たないローカルな日付/時刻のデータ」ですので、そのロケールの中での計算や処理を行えますが、そのままではExactTimeに変換できません。ExactTimeに変換するためには、そのデータがどのタイムゾーンでの日時データであるかを明示的に指定する必要があり、つまりはタイムゾーン情報を持つTemporal.ZonedDateTimeを介して変換する必要があります。

このようにWall-Clock TimeとExactTimeの扱いをAPIレベルで明確に区別することで日時の取り扱いをより安全に行えます。

タイムゾーンとカレンダーのサポート

TemporalはDateオブジェクトと異なり、UTCやシステムのタイムゾーン以外のタイムゾーンをサポートしています。サポートされるTimeZoneは基本的にIANA Time Zone Databaseで管理されているタイムゾーンで、これらのタイムゾーンを指定しローカルな時間とUTC時刻・タイムスタンプとの変換処理などが容易に行えます。もちろんこれらのタイムゾーンにはサマータイムを導入しているものや、過去にタイムゾーン自体のオフセット変更があったものも含みます。Temporalではこのようなサマータイム・オフセット変更も考慮してくれるため、安全にタイムゾーンを扱うことができます。

またTemporalはユダヤ暦や中国の旧暦のようなグレゴリオ暦以外のカレンダーもサポートしています。これらの非グレゴリオ暦は1年が12ヵ月でなかったり、1ヵ月の日数がグレゴリオ暦と大きく異なるといった特徴があります。このような場合でもTemporalでは各暦での年月日から日時を指定したり、逆に特定のタイムスタンプからその暦での年月日を取得できます。

豊富な計算API

Temporalの日時を表すClassには日時計算のメソッドが用意されています。たとえば日時の加算減算を行うadd()/subtract()メソッドや比較をするcompare()/equals()メソッド、日時の差分計算のためのsince()/until()メソッド、丸めのためのround()メソッドなどが用意されています。

またこれらの計算が行いやすいように、日時を表すクラスとは別に日時の差分を表すTemporal.Durationのようなクラスも用意されています。このクラスを併用することで日時の計算がより簡単に行えます。

明確な日時文字列(タイムスタンプ)の定義とパース処理

上記の問題点で挙げたとおり、既存のDateオブジェクトは日時文字列からパースする処理の信頼性が低いという問題がありました。またタイムゾーンやカレンダーの指定もサポートしていなかったため、そもそも解釈できる日時文字列にタイムゾーン情報やカレンダー情報を持たせることができませんでした。

Temporalではこれらの問題を解決するため、パースできる日時文字列を明確にRFC 9557(Internet Extended DateTime Format : IXDTF)形式のものと定義しています。

RFC 9557は既存のJavaScriptなどがサポートするRFC 3339/ISO 8601形式を拡張した新しい日時文字列のフォーマットです。このフォーマットでは以下のようにRFC 3339/ISO 8601にタイムゾーン情報とカレンダー情報を含む拡張情報を付与できる構文となっています。

2025-01-01T00:00:00+09:00[Asia/Tokyo][u-ca-japanese]
// [タイムゾーンID][u-ca=カレンダーID] のような形式の拡張部分

RFC 9557はRFC 3339/ISO 8601の拡張仕様ではありますが、Temporal.ZonedDateTimeなどのタイムゾーンを明確に含むべきデータを生成する際はタイムゾーン部分の情報がないとエラーになるという制約があります。このように、日時文字列のパースを厳格化することでパース処理が安定するようになっています。Temporal.Instantなどのタイムゾーンを持たないデータを生成する際はタイムゾーン部分の情報は省略できますが、基本的にはタイムゾーン情報を含む形式の日時文字列で日時を指定することが推奨されます。

Temporalの基本的な使い方

Temporalの提案された背景やそのメリットを理解したところで、ここからは実際にTemporalのAPI見ていきましょう。

とはいえ、Temporalは非常に多くのAPIや機能を持つのですべてを解説することは難しいです。そこでここではよくありそうな日時操作のユースケースをもとに「Temporalを使うとこうなる」という形で紹介していきます。

サーバから取得した日時情報を表示する

サーバから取得した日時情報を表示する場合、取得した日時情報はUTCの日時フォーマットやタイムスタンプで表現されていることが一般的です。そのため、日時情報を表示するためにはユーザーの利用するローカルタイムゾーンに変換して表示する必要があります。

Temporalではタイムゾーンと日時がペアになったデータとしてTemporal.ZonedDateTimeが用意されており、このクラスのインスタンスはローカルな日時情報の取得や書式化機能を備えています。したがって、サーバから取得した日時情報をTemporal.ZonedDateTimeに変換できれば、その後の日時情報の取り扱いが容易になります。

サーバから取得した日時情報がUTCのタイムゾーン情報を含まない日時フォーマットだった場合、まずはTemporal.Instantのfromメソッドを使ってTemporal.Instantに変換します。その後、toZonedDateTimeISO()メソッドでタイムゾーンを指定することでTemporal.ZonedDateTimeに変換できます。

const savedDateTime = "2025-01-01T00:00:00Z"; // サーバから取得した日時情報
const userTimeZone = "Asia/Tokyo"; // ユーザーのタイムゾーン
const zonedDateTime =
  Temporal.Instant.from(savedDateTime).toZonedDateTimeISO(userTimeZone);

サーバから取得した日時情報がタイムスタンプであった場合はもう少しシンプルで、Temporal.ZonedDateTime初期化時にタイムスタンプとタイムゾーンを指定することで直接Temporal.ZonedDateTimeを生成できます。ただしタイムスタンプはBigIntで表現されたナノ秒単位の値であることには注意が必要です。

const savedTimeStump = 1735689600000; // サーバから取得したタイムスタンプをnano秒に変換
const userTimeZone = "Asia/Tokyo"; // ユーザーのタイムゾーン
const zonedDateTime = new Temporal.ZonedDateTime(
  BigInt(savedTimeStump) * 1000n,
  userTimeZone
);

ナノ秒に変換するのが面倒な場合はTemporal.InstantfromEpochMilliseconds()メソッドを使ってTemporal.Instantを生成してからtoZonedDateTime()メソッドを使ってTemporal.ZonedDateTimeに変換する方法もあります。

const savedTimeStump = 1735689600000; // サーバから取得したタイムスタンプ
const userTimeZone = "Asia/Tokyo"; // ユーザーのタイムゾーン
const zoneDateTime =
  Temporal.Instant.fromEpochMilliseconds(savedTimeStump).toZonedDateTime(
    userTimeZone
  );

このようにして取得した日時情報をTemporal.ZonedDateTimeに変換できれば、Temporal.ZonedDateTimetoLocaleString()メソッドを使ってユーザーのローカルタイムゾーンでの日時文字列を取得できます。

const localeDateTimeString = zonedDateTime.toLocaleString("ja-JP", {
  dateStyle: "medium",
  timeStyle: "short",
});
console.log(localeDateTimeString); // "2025年1月1日水曜日 9:00"

ちなみにTemporal.ZonedDateTimeのtoLocaleStringメソッドの引数はIntl.DateTimeFormatのオプションと同様のものが使用でき、日時のフォーマット自体もIntl.DateTimeFormatと同様の挙動になります。

ユーザーの入力した日時情報からサーバに送る情報を生成する

逆にユーザーが入力した日時情報をサーバに送るための日時情報を生成する場合、ユーザーが入力した日時情報はローカルなタイムゾーンで表現されています。そのため、ユーザーが入力した日時情報をUTCの日時フォーマットやタイムスタンプに変換してサーバに送る必要があります。

この場合もTemporal.ZonedDateTimeを経由することで、UTCの日時フォーマットやタイムスタンプに変換できます。

// UTCの日時フォーマットに変換
const utcDateTimeString = zonedDateTime.toString(). // "2025-01-01T09:00:00+09:00[Asia/Tokyo]"
// ミリ秒のタイムスタンプに変換
const utcDateTimeString = zonedDateTime.toInstant().epochMilliseconds; // "1735689600000"

つまり、ユーザーの入力値とユーザーの利用するタイムゾーンからTemporal.ZonedDateTimeを生成できれば上記のような変換処理を行えることになります。この方法は主に2つの方法があり、1つはTemporal.PlainDateTimeを経由する方法、もう1つがTemporal.ZonedDateTimefrom()メソッドを利用する方法です。

Temporalはタイムゾーン情報を持たないローカルな日時データを表すClassとしてTemporal.PlainDateTimeを持っています。このTemporal.PlainDateTimeは初期化時に第1引数から順に年月日時分秒……のように値を渡すことで生成できます。

const inputDateTime = new Temporal.PlainDateTime(2025, 1, 1, 9, 0); // 2025年1月1日9時0分

PlainDateTime自体はタイムゾーン情報を持たないため、PlainDateTimeにあるtoZonedDateTime()メソッドでタイムゾーンを指定しTemporal.ZonedDateTimeに変換できます。

const userTimeZone = "Asia/Tokyo"; // ユーザーのタイムゾーン
const zonedDateTime = inputDateTime.toZonedDateTime(userTimeZone);

またPlainDateTimeを経由せずに直接Temporal.ZonedDateTimeを生成する方法もあります。Temporal.ZonedDateTimefrom()メソッドを使うことで、年月日時分秒……のような値をオブジェクトして渡しTemporal.ZonedDateTimeを生成できます。

const userTimeZone = "Asia/Tokyo"; // ユーザーのタイムゾーン
const zonedDateTime = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 9,
  minute: 0,
  timeZone: userTimeZone,
});

これだけ見るとTemporal.ZonedDateTimefrom()メソッドのほうが簡潔でわかりやすいように見えますが、実際に使う場合はどのタイミングでタイムゾーン情報を持たせて変換するかがポイントになります。ユーザーの入力値から直接Temporal.ZonedDateTimeを生成しようとする場合、入力を処理する末端の処理で常にユーザーの利用するタイムゾーンを取得する必要があります。一方UTCの日時フォーマットやタイムスタンプに変換する必要があるのは値を保存してサーバなどに情報を送るタイミングですから、このような変換処理はもう少し上位の保存処理や送信処理で行う方が読み易いかもしれません。この場合変換処理までは一時的にPlainDateTimeで保持し、変換処理でTemporal.ZonedDateTimeに変換するという方法も十分に有用です。

JavaScriptで日付の計算を行う

最後に、Temporalを使ってJavaScriptで日付の計算をする際の方法を紹介します。Temporal.ZonedDateTimeTemporal.PlainDateTimeクラスはメソッドとして日時の計算用のメソッドを持っています。

  • add()subtract():日時の加算減算
  • compare()equals():日時の比較
  • since()until():日時の差分計算
  • round():日時の丸め

これらの計算メソッドを使う上で、よく使われるのがTemporal.Durationクラスです。Temporal.Durationクラスは日時の長さを表すクラスで、以下のようにfromメソッドや初期化時に年数や日数などを指定して生成できます。

const duration = Temporal.Duration.from({ days: 1, hours: 12 }); // 1.5日
const duration_ = new Temporal.Duration(0, 0, 0, 1, 12); // 上と同じ

Temporal.Durationクラスは日時の計算メソッドの引数や戻り値として日時の加算減算や差分計算に使われます。

日時の加算減算

add()subtract()メソッドは日時の加算減算を行うメソッドです。これらのメソッドは第1引数にTemporal.Durationを受け取り、そのぶんだけ日時を加算減算します。

add()subtract()メソッドは日時の加算減算を行うメソッドです。これらのメソッドは第1引数にTemporal.Durationを受け取り、そのぶんだけ日時を加算減算します。

const zonedDateTime = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 9,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
const duration = Temporal.Duration.from({ hours: 5 }); // 1.5日
const addedZonedDateTime = zonedDateTime.add(duration); // 2025/01/01 14:00 JST

日時の比較

compare()equals()メソッドは日時を比較するメソッドです。compare()メソッドは静的メソッドで引数に受け取った2つの日時を比較して、その前後関係を返します。第1引数の日時の方が前の場合は-1、第2引数の日時の方が前の場合は1、同じ場合は0を返します。

const zonedDateTime1 = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 9,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
const zonedDateTime2 = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 14,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
const compareResult = Temporal.ZonedDateTime.compare(
  zonedDateTime1,
  zonedDateTime2
); // -1 (zonedDateTime1 < zonedDateTime2 なので)

これはArray.sortなどで受け取るコールバックと同じインタフェースですので、日時の並び替えに利用できます。

equals()メソッドはインスタンスに生えたメソッドで、元の日時と引数で渡された日時が等しいかどうかを返します。

const zonedDateTime1 = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 9,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
const zonedDateTime2 = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 9,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
const equalsResult = zonedDateTime1.equals(zonedDateTime2); // true

日時の差分計算

since()until()メソッドは日時の差分計算をするメソッドです。これらは両方とも引数に渡された日時との差分をTemporal.Durationで返します。since()メソッドは元の日時が引数の日時よりも後の場合は正の値、前の場合は負の値を返します。逆にuntil()メソッドは元の日時が引数の日時よりも後の場合は負の値、前の場合は正の値を返します。

const zonedDateTime1 = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 9,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
const zonedDateTime2 = Temporal.ZonedDateTime.from({
  year: 2025,
  month: 1,
  day: 1,
  hour: 14,
  minute: 0,
  timeZone: "Asia/Tokyo",
});
// 以下は両方 zonedDateTime2 - zonedDateTime1 を意味する
const duration = zonedDateTime2.since(zonedDateTime1); // 5時間
const duration2 = zonedDateTime1.until(zonedDateTime2); // 5時間

サポート状況と今後

最後にTemporalのサポート状況と今後の展望について見ていきましょう。

序盤に述べたとおり、TemporalはStage3の提案であり、まだ正式な仕様としては策定されていません。ただStage3は仕様策定中の最後の段階で試験的な実装が推奨されている状態です。そのため今後いくつかのランタイムで試験的なサポートが始まり、問題がなければ正式な仕様として策定される可能性が高いです。

2025年1月現在の各ブラウザのサポート状況は以下のとおりです。

またNode.jsやDenoなどの非ブラウザランタイムでもサポート状況は以下のようになっています。

  • Node.js:サポートそれておらず実装も未定
  • Deno:version 1.40から--unstable-temporalフラグで試験的なサポートが始まっている

このようにランタイムでのサポートはまだまだなTemporalですが、いくつかPolyfillが存在しているので使用感を試すこともできます。

まとめ

今回は新しい日時操作API Temporalについて紹介しました。

まだまだ使える環境が限られているものの、JavaScriptの日時操作における問題点の多くを解決したAPIであり、今までライブラリ利用前提だった日時操作がこの標準前提になる日も近いかもしれません。ぜひ今のうちにTemporalのAPIやその設計思想、使い方を知って技術の選定などにも役立てくれるとうれしいです。

おすすめ記事

記事・ニュース一覧