Temporal で JavaScript の次世代の日時処理に触れてみる

この記事は [Recruit Advent Calendar 2021](https://adventar.org/calendars/6663) の 7 日目の記事です。

## イントロダクション

Web アプリケーションの新規開発をしていて、先日、日時処理のライブラリ選定をする機会がありました。直近のプロジェクトで [date-fns](https://date-fns.org/) を使っていたので、今回もそれでいいかと考えていたのですが、Temporal　が TC39 プロポーザルではあるが stage　3 になっているから試してみてもいいんじゃないかという話になりました。stage 3 であれば　　API 変更のハードルは非常に高いそうです。stage 3 になるまでの提案は[こちら](https://qiita.com/uhyo/items/5f34f5d6f33aa091a104)の記事が参考になります。少し見ただけでも大きく変わっていることがわかります。

プロジェクトはまだ開発段階ですが、実際に Temporal をプロジェクトに導入しながら、検証兼開発を進めていいます。しかし、Temporal を実際に使ってみると、思ったように手が動きませんでした。はじめて使う API ではあるので、慣れていないのは当然ですが、[Moment.js](https://momentjs.com/) や date-fns などの日時ライブラリにや [Date](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Date) に飼い慣らされているので、感覚的にとっつきにくさを感じました。幸い Temporal は[ドキュメント](https://tc39.es/proposal-temporal/docs/index.html)がかなりしっかりしているので、このドキュメントだけで（時間があれば）十分に学習できます。

なお、アドベントカレンダー 9 日目を担当する sititou70 により、一部ドキュメントが[日本語訳](https://tc39.es/proposal-temporal/docs/ja/index.html)されています。すばらしいですね！

ref. [TC39 Temporal のドキュメントを一部翻訳しました](https://sititou70.github.io/TC39-Temporal%E3%81%AE%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88%E3%82%92%E4%B8%80%E9%83%A8%E7%BF%BB%E8%A8%B3%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F/)

この記事は、Temporal の勘所をおさえて、日時処理の基本となりそうなユースケースを説明します。現場で使う時のとっかかりになり、ドキュメントを理解しやすくなることが狙いです。

## Temporal について

[Temporal](https://github.com/tc39/proposal-temporal) は日時を操作する API を提供するグローバルオブジェクトです。既存の Date の問題点を解決が Temporal 策定における 1 つのモチベーションとのことです。

現在のところ、Temporal を使用するには[ポリフィル](https://www.npmjs.com/package/@js-temporal/polyfill)が必要です。stage 3 まできているので、そろそろ TypeScript が実装してくれないかなと期待を抱いています。

### Temporal の肝

Temporal の API をすんなり使うためには、以下の 2 点をおさえておくといいと感じました。

1. Exact Time と Wall-Clock Time の違いを理解する
1. Temporal 各 API の用途を理解する

#### Exact Time と Wall-Clock Time

[Temporal におけるタイムゾーンとサマータイム、曖昧性の解決](https://tc39.es/proposal-temporal/docs/ja/ambiguity.html)から引用します。

> 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

以下はドキュメントから引用した各オブジェクトの関係図です。

![object-model.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20905/fbae2c7c-9580-9efe-aff5-fa8ebc427034.png)

図の中に `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 の説明をしていません。それぞれのオブジェクトは固有の文字列表現をもっているので、その文字列表現で違いを確認します。こちらもドキュメントにある図を引用します。

![persistence-model.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20905/4ebaac1a-49e8-3641-03d3-32a9e2822e72.png)

`Plain` 群は UTC オフセットやタイムゾーンが関連付けられていないことがわかります。`Instant` には UTC オフセットがあるが、タイムゾーンは関連付けられておらず、ISO 8601 / RFC 3339 規格で表現できることがわかります。`ZonedDateTime` はすべての範囲をカバーしています。`ZonedDateTime` がすべての情報をもっているので、これだけ使えばいいと短絡的に考えてしまいそうです。しかし、すべての情報を揃えられるケースは少ないですし、ユースケースを限定してバグを防ぐ狙いもあるので、適切な API を使うようにしましょう。

各オブジェクトに文字列表現があるということは、`toString()` でどのような情報をもっているかがおおよそ把握できるということです。

```js
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` 以外のオブジェクトであれば使うことができます。

```js
console.log(plainDate.toString({ calendarName: "always" })); // 2020-08-05[u-ca=iso8601]
```

## ユースケース

上述の Temporal の勘所をおさえたら、アプリケーション開発でよくありそうなユースケースをみていきましょう。

### 現在日時を扱う

現在の日付や時間を扱う場合は `Temporal.Now` のメソッドを使います。

ローカル時間（wall-clock time）は `Plain` 系のオブジェクトを使います。`Temporal.Now.plainXXX` のようなメソッドがあるのでそれを使います。

```js
Temporal.Now.plainDateTimeISO().toString(); // ex. 2021-12-02T18:14:28.704468703
```

ISO8601 カレンダーを使うので `ISO` とついています。通常の日付を使うのであれば、ISO8601 カレンダーで問題ありません。`ISO` がついていないメソッドもありますが、こちらは使用するカレンダーを指定する必要があります。

```js
// ISO8601 カレンダーを指定しているので、plainDateTimeISO と同じ
Temporal.Now.plainDateTime({ calendar: "iso8601" }).toString();
```

上記では、タイムゾーンを指定していないので、システムのタイムゾーンが使用されます。引数でタイムゾーンを指定することもできます。

```js
Temporal.Now.plainDateTimeISO("Asia/Tokyo").toString();
```

また、システムのタイムゾーンを取得することもできます。

```js
Temporal.Now.timeZone().toString(); // ex. Asia/Tokyo
```

余談ですが、システムのタイムゾーンを使う場合は注意する必要があります。JavaScript が動く環境はブラウザだけでなく、Node.js サーバーであったり、最近では Edge クラウドであったりします。同じシステムでも CSR のときは Asia/Tokyo なのに、SSR のときは UTC になっているということもあるかもしれません。

exact time の場合は、`Instant` オブジェクトを使います。`Instant` オブジェクトは `Temporal.Now.instant` が利用できます。

```js
console.log(Temporal.Now.instant().toString()); // ex. 2021-12-02T09:38:18.689898688Z
```

Unix タイムスタンプを取得することもできます。秒からナノ秒までの API が用意されているようですね。

```js
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 以上の情報を持っていない文字列を渡すとインスタンス化に失敗します。

```js
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](https://tc39.es/proposal-temporal/docs/ja/iso-string-ext.html)

`ZonedDateTime` でも同様の結果になります。

```js
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` の例です。

```js
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 形式ベースの文字列で渡す必要があります。

### 日時文字列をフォーマット

こちらのユースケースも頻出だと思いますが、残念ながら日時ライブラリでお馴染みの以下のようなフォーマット方法は用意されていません。

```js
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 形式を逸脱することはできず、タイムゾーンの表示や秒や分部分の調整ができるだけです。

```js
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`](https://tc39.es/ecma402/#datetimeformat-objects) を使うことができます。ただ、こちらも `DateTimeFormat` の範囲内でフォーマットするので、完全に自由なフォーマットができるわけではありません。

```js
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 のみを扱うので、年月日や時間、およびタイムゾーンの情報が存在しません。

```js
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 の概要を理解していればドキュメントをみて調べることができると思います。日本語訳はされていませんが、ドキュメントに[クックブック](https://tc39.es/proposal-temporal/docs/cookbook.html)があるので、そちらも目を通すとよさそうです。個人的は Temporal を通して、日時処理を学習できたことがよかったです。Temporal を使わなくても Temporal のドキュメントは非常に有用だと思いました。