この記事は Recruit Advent Calendar 2021 の 7 日目の記事です。
イントロダクション
Web アプリケーションの新規開発をしていて、先日、日時処理のライブラリ選定をする機会がありました。直近のプロジェクトで date-fns を使っていたので、今回もそれでいいかと考えていたのですが、Temporal が TC39 プロポーザルではあるが stage 3 になっているから試してみてもいいんじゃないかという話になりました。stage 3 であれば API 変更のハードルは非常に高いそうです。stage 3 になるまでの提案はこちらの記事が参考になります。少し見ただけでも大きく変わっていることがわかります。
プロジェクトはまだ開発段階ですが、実際に Temporal をプロジェクトに導入しながら、検証兼開発を進めていいます。しかし、Temporal を実際に使ってみると、思ったように手が動きませんでした。はじめて使う API ではあるので、慣れていないのは当然ですが、Moment.js や date-fns などの日時ライブラリにや Date に飼い慣らされているので、感覚的にとっつきにくさを感じました。幸い Temporal はドキュメントがかなりしっかりしているので、このドキュメントだけで(時間があれば)十分に学習できます。
なお、アドベントカレンダー 9 日目を担当する sititou70 により、一部ドキュメントが日本語訳されています。すばらしいですね!
ref. TC39 Temporal のドキュメントを一部翻訳しました
この記事は、Temporal の勘所をおさえて、日時処理の基本となりそうなユースケースを説明します。現場で使う時のとっかかりになり、ドキュメントを理解しやすくなることが狙いです。
Temporal について
Temporal は日時を操作する API を提供するグローバルオブジェクトです。既存の Date の問題点を解決が Temporal 策定における 1 つのモチベーションとのことです。
現在のところ、Temporal を使用するにはポリフィルが必要です。stage 3 まできているので、そろそろ TypeScript が実装してくれないかなと期待を抱いています。
Temporal の肝
Temporal の API をすんなり使うためには、以下の 2 点をおさえておくといいと感じました。
- Exact Time と Wall-Clock Time の違いを理解する
- Temporal 各 API の用途を理解する
Exact Time と Wall-Clock Time
Temporal におけるタイムゾーンとサマータイム、曖昧性の解決から引用します。
Temporal の中核となるコンセプトは、wall-clock time(ローカル時間 や clock time とも呼ばれる、タイムゾーンに依存した時刻)と exact time(UTC 時間 とも呼ばれる、地球上のどこでも同じ時刻)を区別することです。
wall-clock time は地方政府によって制御されているため、突然変更される可能性があります。サマータイムが導入されたり、ある国のタイムゾーンが他のものに変更されたりすると、ローカル時間は即座に変更されます。exact time は変更されない国際的な定義を持っており、UTC という特別なタイムゾーンで呼ばれています。
exact time は ISO 8601 / RFC 3339 規格であれば 2020-09-06T17:35:24.485Z のように表現するか、Unix 時間で表現します。一方で、wall-clock time は +09:00 のような UTC オフセットで 2020-09-06T17:35:24.485+09:00 のように表現します。Temporal の API 群はこの 2 つの time を区別する設計になっています。
Temporal API
とにかく多いです。日付けライブラリの API に慣れている人はどれを使ったらいいか迷うと思います。ドキュメントにある Temporal 直下の API を列挙します。
- Temporal.Instant
- Temporal.ZonedDateTime
- Temporal.PlainDate
- Temporal.PlainTime
- Temporal.PlainDateTime
- Temporal.PlainYearMonth
- Temporal.PlainMonthDay
- Temporal.TimeZone
- Temporal.Calendar
- Temporal.Duration
- Temporal.Now
以下はドキュメントから引用した各オブジェクトの関係図です。
図の中に Temporal.Now
が存在しません。Temporal.Now
は名前の通り現在の日付や時間を扱うメソッド群が集約されています。図中のオブジェクトを使って現在の日時と時間を扱うだけなので、他のオブジェクトを理解していればとくに問題ありません。
関係図から、Timezone
、Calendar
および、Duration
は、exact time や wall-clock time を扱う上で補助的な役割になることが伺えます。ポイントは exact time を表現するのは Instant
と ZonedDateTime
、wall-clock time を表現するのは ZonedDateTime
と Plain
から始まるクラス群であるということです。
つまり、日時処理で中心的になるのは以下の 3 つです(Plain
で始まるクラスを PlainXXX
としています)。日時処理をする際は、これら 3 つの API を中心に考えましょう。
- Temporal.Instant
- Temporal.ZonedDateTime
- Temporal.PlainXXX
Temporal では exact time や wall-clock time を扱うクラスを分けていたり、Plain
から始まるクラスがいくつかあったりと、ユースケースによってクラスが分かれていることをおさえておくことが重要です。ユースケースを限定することにより、日時処理コードの可読性向上や、タイムゾーンが不明な時間に対して間違ったオフセットを与えてしまうようなバグを防ぐことが狙いのようです。
ここまで各 API の説明をしていません。それぞれのオブジェクトは固有の文字列表現をもっているので、その文字列表現で違いを確認します。こちらもドキュメントにある図を引用します。
Plain
群は UTC オフセットやタイムゾーンが関連付けられていないことがわかります。Instant
には UTC オフセットがあるが、タイムゾーンは関連付けられておらず、ISO 8601 / RFC 3339 規格で表現できることがわかります。ZonedDateTime
はすべての範囲をカバーしています。ZonedDateTime
がすべての情報をもっているので、これだけ使えばいいと短絡的に考えてしまいそうです。しかし、すべての情報を揃えられるケースは少ないですし、ユースケースを限定してバグを防ぐ狙いもあるので、適切な API を使うようにしましょう。
各オブジェクトに文字列表現があるということは、toString()
でどのような情報をもっているかがおおよそ把握できるということです。
import { Temporal } from "@js-temporal/polyfill";
const dateString = "2020-08-05T20:06:13+09:00[Asia/Tokyo]";
const plainDate = Temporal.PlainDate.from(dateString);
console.log(plainDate.toString()); // 2020-08-05
const plainDateTime = Temporal.PlainDateTime.from(dateString);
console.log(plainDateTime.toString()); // 2020-08-05T20:06:13
const instant = Temporal.Instant.from(dateString);
console.log(instant.toString()); // 2020-08-05T11:06:13Z、timeZone オプションなしだと UTC 時間になる
console.log(instant.toString({ timeZone: "Asia/Tokyo" })); // 2020-08-05T20:06:13+09:00
const zonedDateTime = Temporal.ZonedDateTime.from(dateString);
console.log(zonedDateTime.toString()); // 2020-08-05T20:06:13+09:00[Asia/Tokyo]
また、図に補足されているように、Calendar
拡張部分は Instant
以外のオブジェクトであれば使うことができます。
console.log(plainDate.toString({ calendarName: "always" })); // 2020-08-05[u-ca=iso8601]
ユースケース
上述の Temporal の勘所をおさえたら、アプリケーション開発でよくありそうなユースケースをみていきましょう。
現在日時を扱う
現在の日付や時間を扱う場合は Temporal.Now
のメソッドを使います。
ローカル時間(wall-clock time)は Plain
系のオブジェクトを使います。Temporal.Now.plainXXX
のようなメソッドがあるのでそれを使います。
Temporal.Now.plainDateTimeISO().toString(); // ex. 2021-12-02T18:14:28.704468703
ISO8601 カレンダーを使うので ISO
とついています。通常の日付を使うのであれば、ISO8601 カレンダーで問題ありません。ISO
がついていないメソッドもありますが、こちらは使用するカレンダーを指定する必要があります。
// ISO8601 カレンダーを指定しているので、plainDateTimeISO と同じ
Temporal.Now.plainDateTime({ calendar: "iso8601" }).toString();
上記では、タイムゾーンを指定していないので、システムのタイムゾーンが使用されます。引数でタイムゾーンを指定することもできます。
Temporal.Now.plainDateTimeISO("Asia/Tokyo").toString();
また、システムのタイムゾーンを取得することもできます。
Temporal.Now.timeZone().toString(); // ex. Asia/Tokyo
余談ですが、システムのタイムゾーンを使う場合は注意する必要があります。JavaScript が動く環境はブラウザだけでなく、Node.js サーバーであったり、最近では Edge クラウドであったりします。同じシステムでも CSR のときは Asia/Tokyo なのに、SSR のときは UTC になっているということもあるかもしれません。
exact time の場合は、Instant
オブジェクトを使います。Instant
オブジェクトは Temporal.Now.instant
が利用できます。
console.log(Temporal.Now.instant().toString()); // ex. 2021-12-02T09:38:18.689898688Z
Unix タイムスタンプを取得することもできます。秒からナノ秒までの API が用意されているようですね。
const instant = Temporal.Now.instant();
console.log(instant.epochSeconds); // ex. 1638437898
console.log(instant.epochMilliseconds); // ex. 1638437898698
console.log(instant.epochMicroseconds); // ex. 1638437898698898n
console.log(instant.epochNanoseconds); // ex. 1638437898698898689n
日時文字列をパースして日時オブジェクトを生成する
日付や時刻の文字列をパースして、オブジェクトを生成することは、よくあるユースケースかと思います。Temporal の API 各 API は厳格に定義された文字列をパースします。日時を扱う各クラスには from
という static メソッドが用意されているので、このメソッドで文字列をパースします。扱う日時文字列の情報量を意識して文字列をパースする必要があります。
Instant
は exact time を表現します。ISO 8601 形式を渡す必要があります。タイムゾーンは渡せますが無視されます。ISO 8601 以上の情報を持っていない文字列を渡すとインスタンス化に失敗します。
let instant = Temporal.Instant.from("2020-08-05T20:06:13+09:00[Asia/Tokyo]");
console.log(instant.toString()); // 2020-08-05T11:06:13Z、タイムゾーンは無視される
instant = Temporal.Instant.from("2020-08-05T20:06:13+09:00");
console.log(instant.toString()); // 2020-08-05T11:06:13Z
instant = Temporal.Instant.from("2020-08-05T20:06:13"); // throw RangeError、UTC オフセットが足りないのでエラーが発生する
[Asia/Tokyo] のようなタイムゾーン表記は、ISO-8601 / RFC 3339 ではカバーされていませんが、事実上の業界標準として採用しているそうです。
ref. ECMAScript 拡張の ISO-8601 と RFC 3339
ZonedDateTime
でも同様の結果になります。
let zonedDateTime = Temporal.ZonedDateTime.from(
"2020-08-05T20:06:13+09:00[Asia/Tokyo]"
);
console.log(zonedDateTime.toString()); // 2020-08-05T20:06:13+09:00[Asia/Tokyo]
zonedDateTime = Temporal.ZonedDateTime.from("2020-08-05T20:06:13+09:00"); // throw RangeError
console.log(zonedDateTime.toString()); // 2020-08-05T11:06:13Z
Plain
系のクラスでも同様の結果です。以下は PlainDate
と PlainTime
の例です。
let plainDate = Temporal.PlainDate.from(
"2020-08-05T20:06:13+09:00[Asia/Tokyo]"
);
console.log(plainDate.toString()); // 2020-08-05
plainDate = Temporal.PlainDate.from("2020-08"); // throw RangeError
let plainTime = Temporal.PlainTime.from(
"2020-08-05T20:06:13+09:00[Asia/Tokyo]"
);
console.log(plainTime.toString()); // 20:06:13
plainTime = Temporal.PlainTime.from("20:06:13");
console.log(plainTime.toString()); // 20:06:13
plainTime = Temporal.PlainTime.from("20");
console.log(plainTime.toString()); // 20:00:00
いずれにしても、それぞれの日時オブジェクトを表現するために必要な情報を from
メソッドの引数に ISO 8601 形式ベースの文字列で渡す必要があります。
日時文字列をフォーマット
こちらのユースケースも頻出だと思いますが、残念ながら日時ライブラリでお馴染みの以下のようなフォーマット方法は用意されていません。
import { format } from "date-fns";
format(new Date(2016, 0, 1), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); // 2016-01-01T00:00:00.000+09:00
Temporal で実現できるのは以下の 3 点です。
- 各日時オブジェクトの
toString()
のオプションを使う -
DateTimeFormat
を使う - 自力でフォーマットする
各日時オブジェクトの toString()
のオプションを使う
こちらはすでに出てきた toString()
にオプションを渡す方法です。ただ、ISO 8601 形式を逸脱することはできず、タイムゾーンの表示や秒や分部分の調整ができるだけです。
let instant = Temporal.Instant.from("2020-08-05T20:06:13+09:00[Asia/Tokyo]");
console.log(instant.toString()); // 2020-08-05T11:06:13Z
console.log(instant.toString({ timeZone: "Asia/Tokyo" })); // 2020-08-05T20:06:13+09:00
console.log(instant.toString({ smallestUnit: "minute" })); // 2020-08-05T11:06Z
console.log(instant.toString({ smallestUnit: "second" })); // 2020-08-05T11:06:13Z
console.log(instant.toString({ smallestUnit: "millisecond" })); // 2020-08-05T11:06:13.000Z
DateTimeFormat
を使う
日時オブジェクトの toLocaleString()
メソッドで Intl の DateTimeFormat
を使うことができます。ただ、こちらも DateTimeFormat
の範囲内でフォーマットするので、完全に自由なフォーマットができるわけではありません。
let instant = Temporal.Instant.from("2020-08-05T20:06:13+09:00[Asia/Tokyo]");
console.log(
instant.toLocaleString("ja-jp", {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
); // 20/08/05 20:06:13
console.log(
instant.toLocaleString("ja-jp", {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
})
); // 2020/8/5 20:06:13
自力でフォーマットする
こちらは Temporal の日時オブジェクトから得られる年月日や時間の情報から自力で組み立てる方法です。このとき使えるオブジェクトは ZonedDateTime
か Plain
系のオブジェクトであり、Instant
はそのままでは使えません。Instant
は exact time のみを扱うので、年月日や時間、およびタイムゾーンの情報が存在しません。
const zonedDateTime = Temporal.ZonedDateTime.from("2020-08-05T20:06:13+09:00[Asia/Tokyo]")
console.log(zonedDateTime.year) // 2020
console.log(zonedDateTime.month) // 8
console.log(zonedDateTime.day) // 5
console.log(zonedDateTime.hour) // 20
console.log(zonedDateTime.minute) // 6
console.log(zonedDateTime.second) // 13
console.log(zonedDateTime.offset) // +09:00
console.log(zonedDateTime.timeZone.toString()) // Asia/Tokyo
フォーマットに必要な情報はすべて取得できますが、実際にフォーマットする場合はゼロ埋めなどの処理も必要になってくるので、毎回手動でフォーマットしていくのは厳しいですね。関数などに切り出す必要がありそうです。
おわりに
Temporal の概要と使い方について簡単に説明しました。日時処理のユースケースは他にもまだまだあると思いますが(例えば、日時オブジェクトの変換・比較、タイムゾーンの変換、日時の差分取得など)、Temporal の概要を理解していればドキュメントをみて調べることができると思います。日本語訳はされていませんが、ドキュメントにクックブックがあるので、そちらも目を通すとよさそうです。個人的は Temporal を通して、日時処理を学習できたことがよかったです。Temporal を使わなくても Temporal のドキュメントは非常に有用だと思いました。