JavaScript: date-fnsでタイムゾーンを扱う

この投稿ではJavaScriptの日時ライブラリ[date-fns]でタイムゾーンを扱う方法を説明します。

[date-fns]: https://date-fns.org/

## 基本知識

date-fnsはJavaScriptの[Date]を扱うヘルパー関数のセットなので、そもそも`Date`についてよく知っておく必要があります。

[Date]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date

### JavaScriptの`Date`にはタイムゾーンを表すデータが無い

JavaScriptの`Date`オブジェクトはタイムゾーンを表すデータを持ちません。

実行環境のタイムゾーン設定を変更したとしても、`new Date()`はUTC時刻になります:

```bash
$ TZ=Asia/Tokyo node -e 'console.log(new Date())'
2020-07-29T00:27:44.573Z

$ TZ=UTC node -e 'console.log(new Date())'
2020-07-29T00:27:50.167Z
```

`Date`のコンストラクタは[ISO 8601]の表記をパースできますが、タイムゾーン指定子(`+09:00`など)をつけたとしても、UTC時刻に直されます:

[ISO 8601]: https://ja.wikipedia.org/wiki/ISO_8601

```javascript
console.log(new Date('2000-01-01T00:00:00+09:00'))
//=> 1999-12-31T15:00:00.000Z
```

`Date`はUTC時刻の1970年1月1日0時0分0秒を`number`の`0`と規定し、その既定値からの相対的な秒数をラップして、日時処理のAPIを生やしたような素朴なオブジェクトなので、タイムゾーンを表すデータを持たないのも理解できるかと思います。


## date-fnsでのタイムゾーンの扱い

### date-fnsとdate-fns-tz

date-fnsの姉妹パッケージにdate-fns-tzがあります。date-fns-tzはタイムゾーンを扱うためのパッケージですが、これが必要となるケースと不要なケースがあります。個別に見ていきます。

### ISO 8601の表記をパースする

#### タイムゾーン指定子がある場合

タイムゾーン指定子を持ったISO 8601形式の日時のパースは、date-fnsの`parseISO`でできます。date-fns-tzは不要です。パースされた日時はUTCになります:

```typescript
import { parseISO } from 'date-fns'

const utcDate: Date = parseISO('2000-01-01T00:00:00+09:00')
console.log(utcDate)
//=> 1999-12-31T15:00:00.000Z
```

タイムゾーンに関して言うと、`Date()`コンストラクタと同じ挙動になります:

```typescript
const utcDate1: Date = parseISO('2000-01-01T00:00:00+09:00')
const utcDate2: Date = new Date('2000-01-01T00:00:00+09:00')
console.log(utcDate1.getTime() === utcDate2.getTime())
//=> true
```

#### タイムゾーン指定子が無い場合

タイムゾーン指定子を持たないISO 8601表記を、タイムゾーンを指定しながらパースする場合は、date-fns-tzの`zonedTimeToUtc`を使います。関数の名が示すとおり、戻り値の`Date`オブジェクトはUTC時刻になります。

```typescript
import { zonedTimeToUtc } from 'date-fns-tz'

const utcDate: Date = zonedTimeToUtc('2000-01-01T00:00:00', 'Asia/Tokyo')
//=> 1999-12-31T15:00:00.000Z
```

もし、この場合に、`parseISO`を使ってしまうと、この`"2000-01-01T00:00:00"`はUTCのタイムゾーンを指すものと解釈されるので注意が必要です:

```typescript
const utcDate: Date = parseISO('2000-01-01T00:00:00')
//=> 2000-01-01T00:00:00.000Z
```

### `Date`オブジェクトをフォーマットする

date-fnsの`format()`関数とは別に、date-fns-tzにはタイムゾーンを指定できる`format()`関数がありますが、この取扱には注意が必要です。

#### date-fns-tzの`format`はタイムゾーン指定による時刻変更はしない

`format`はタイムゾーン指定による時刻変更はしません。

```typescript
import { format } from 'date-fns-tz'

const utcDate = new Date('2000-01-01T00:00:00')
//=> 2000-01-01T00:00:00.000Z

format(utcDate, 'yyyy-MM-dd HH:mm:ss', { timeZone: 'Asia/Tokyo' })
//=> "2000-01-01 00:00:00"
```

ご覧のとおり、UTCの0時は日本時間で9時のはずですが、フォーマットされた時刻は0時のままです。`timeZone`を`"UTC"`にすると全く同じ文字列にフォーマットされることから、タイムゾーン指定による時間帯の変更はこの関数では行われないことが分かります:

```typescript
format(utcDate, 'yyyy-MM-dd HH:mm:ss', { timeZone: 'UTC' })
//=> "2000-01-01 00:00:00"
format(utcDate, 'yyyy-MM-dd HH:mm:ss', { timeZone: 'Asia/Tokyo' })
//=> "2000-01-01 00:00:00"
```

ではdate-fns-tzの`format`関数は何をするかというと、`z`や`x`のようなタイムゾーン書式に指定されたタイムゾーンを出すことを行ってくれます。

```typescript
format(utcDate, 'yyyy-MM-dd HH:mm:ss xxx', { timeZone: 'UTC' })
//=> "2000-01-01 00:00:00 +00:00"
format(utcDate, 'yyyy-MM-dd HH:mm:ss xxx', { timeZone: 'Asia/Tokyo' })
//=> "2000-01-01 00:00:00 +09:00"
```

したがって、タイムゾーンを書式化する必要がない場合は、date-fns-tzの`format`関数を使う理由はありません。

#### UTCな`Date`をAsia/Tokyoの日時にフォーマットする

UTCな`Date`オブジェクトを日本時間の日時文字列にフォーマットする場合は、次の手順を踏みます:

1. 一旦、date-fns-tzの`utcToZonedTime`関数で、UTCな`Date`オブジェクトを日本時間にずらした`Date`オブジェクトに変換する。
2. 変換後の`Date`オブジェクトを`format`関数でフォーマットする

```typescript
import { format } from 'date-fns'
import { utcToZonedTime } from 'date-fns-tz'

const utcDate = new Date('2000-01-01T00:00:00')
//=> 2000-01-01T00:00:00.000Z

const jstDate = utcToZonedTime(utcDate, 'Asia/Tokyo')
//=> 2000-01-01T09:00:00.000Z

const jstString = format(jstDate, 'yyyy-MM-dd HH:mm:ss')
//=> "2000-01-01 09:00:00"
```

この`format`関数はdate-fnsのものであることに注意してください。もし、タイムゾーン表記をフォーマット後の文字列に含める場合は、date-fns-tzのほうの`format`関数を`timeZone`オプションを指定しつつ使う必要があります。誤って、date-fnsの`format`関数を使うとUTCのタイムゾーン表記になってしまいます:

```typescript
import { format } from 'date-fns'
import { format as formatTZ } from 'date-fns-tz'

format(jstDate, 'yyyy-MM-dd HH:mm:ss xxx')
//=> "2000-01-01 09:00:00 +00:00" ← UTCの表記

formatTZ(jstDate, 'yyyy-MM-dd HH:mm:ss xxx', { timeZone: 'Asia/Tokyo' })
//=> "2000-01-01 09:00:00 +09:00" ← 日本時間の表記
```