🤮

JavaScript における Date のタイムゾーンの挙動と対策

2022/08/22に公開

概要

JavaScript の Date 型は、事故を起こす可能性が比較的高いです。(と思っている。)実際にテストで想定外の挙動をしている部分があり苦戦したのでまとめておきます。

結論としては、

  • データのやり取りの際は必ずタイムゾーンを付与する(当たり前)
  • 日付系ライブラリを使っても変換した瞬間 Date 型の制約には引っかかってしまう
  • データベースとやりとりするシステムではできるだけ UTC を使った方が安全である

です。

一つ目に関しては JavaScript に限ったことではないですが、JavaScript だとエラーなくそのまま勝手に解釈されて動いてしまうので注意が必要です。

Date の制約

Date には「任意のタイムゾーンの時刻を扱うことはできず、絶対にシステムのタイムゾーン(または process.env.TZ に指定されたタイムゾーン)の Date オブジェクトになる」という制約があります。
また、getUTCXxx() というメソッドが実装されているので、現在のタイムゾーンの時刻または UTC の時刻なら追加の実装なく取得することが可能です。
また、 toISOString() はブラウザと Node.js 16系を試した限りでは、UTC の ISO8601 の文字列(e.g. 2022-08-04T15:19:46.125Z) しか得られずタイムゾーンは付与されていませんでした。

なぜ JavaScript の Date はこれほどにもややこしいのか

個人的な考えとしては、タイムゾーンを指定できないことと、タイムゾーンのない文字列から初期化したときにエラーにならずに勝手に解釈されて動いてしまうことにあると思います。
他の言語では timestamp (= このポストでは日付 + 時刻 + タイムゾーンのこと)を扱う際にタイムゾーンつきの DateTime 型などが用意されており、嫌でも意識させられます。Java のようにもはやタイムゾーンを指定しないと初期化すらできない言語もあります。(ZonedDateTime
しかし、JavaScript では最悪タイムゾーンという概念を知らなくても、ほとんどいい感じに動いてしまうため普段の開発では蔑ろにしてしまいがちです。また、タイムゾーンを指定できないのにタイムゾーンがあるという仕様も特殊なため混乱の元になっています。

恥ずかしながら筆者は、「JavaScript の Date はよくわからんから脳死で dayjs 使えばええやん。サーバーとクライアントのやり取りは ISOString でやっとけば dayjs があとはいい感じにしてくれるやろ。」と思っていました。
結果的には、 ISOString がタイムゾーンつきの文字列であること、サーバーサイドが UTC であったことが功を奏して、事故ることはありませんでしたが、ちゃんと最初に調査をすべきだったと反省しています。(幸いテストを書いていたときにローカルと CI で結果が異なることに頭を悩ませるだけで済みました。)

Date の挙動

挙動を要約すると以下の3点です。

  • 何も指定せずに初期化した場合は、現在のタイムゾーンの現在の時刻を持つ Date として初期化される
  • タイムゾーン付き文字列で初期化した際は、タイムゾーンを解釈し現在のタイムゾーンに変換された Date として初期化される
  • (⚠️要注意)タイムゾーンを指定してなかった場合は、現在のタイムゾーンでの時刻であるとして解釈された Date として初期化される

この中で3つ目のケースでは期待した timestamp とは違うケースになることがあるので注意が必要です。また、ORM の挙動によってはなぜかタイムゾーンを無視して、その timestamp だけを UTC として切り取って保存してしまうというケースに出会いました。(後述)そのため最初に述べたようにサーバーサイドなど、DB とやり取りをするシステムでは UTC を使うことがおすすめです。
(主にこの2点に引っかかって苦労したことがこの記事を書き始めたきっかけです。)

パターン1: 何も指定せずに初期化した場合

そのタイムゾーンの現在時刻が取得されます。これは素直な挙動ですね。

> new Date().toString()
'Sun Aug 21 2022 12:59:26 GMT+0000 (GMT)'
'Sun Aug 21 2022 21:59:53 GMT+0900 (日本標準時)'

パターン2: タイムゾーンつき文字列で初期化した場合

タイムゾーン付き文字列で初期化した際は、タイムゾーンを解釈し現在のタイムゾーンに変換された Date として初期化されます。これも素直な挙動です。
また、ここで言うタイムゾーン付き文字列とは、UTC の ISO8601 の文字列(e.g. 2022-08-04T15:19:46.125Z)のことを指しています。

> new Date("2022-08-04T15:19:46.125Z").toString()
"Thu Aug 04 2022 15:19:46 GMT+0000 (GMT)" // タイムゾーンが UTC の場合
"Fri Aug 05 2022 00:19:46 GMT+0900 (日本標準時)" // タイムゾーンが JST の場合

パターン3: (⚠️要注意)タイムゾーンなし文字列で初期化した場合

タイムゾーンなし文字列で初期化した場合は、現在のタイムゾーンでその時刻であると解釈された Date として初期化されます。
そしてこの場合、タイムゾーンの情報が意図しない形で解釈されてしまう可能性があるので注意が必要です。クライアントサイドでは 10:00:00 JST のつもりだったのに、サーバーでは 10:00:00 UTC として解釈されてしまうことがあるのです。

> new Date("2022-08-04T15:19:46.125").toString()
// 同じ日付なのにシステムのタイムゾーンによって時刻がずれてしまう。
"Thu Aug 04 2022 15:19:46 GMT+0000 (GMT)" // UTC の場合
"Thu Aug 04 2022 15:19:46 GMT+0900 (日本標準時)" // JST の場合

このケースが起こり得ないように、データのやりとりをする際にはしっかりとタイムゾーン付きの timestamp 文字列で扱いましょう。

なぜかタイムゾーンを無視する ORM

なぜかタイムゾーンを無視する ORM も存在するので、特段の要件がない限りは UTC で扱うのが無難です。(ほとんどの場合はタイムゾーンが適切に設定されていたら、どのタイムゾーンでも問題ありませんが。)

TypeScript の有名な ORM として prisma というライブラリがあります。非常によくできていて使い勝手に不満はないのですが、なぜかタイムゾーンを指定できないこと、さらに UTC 以外の Date オブジェクトを勝手にタイムゾーンを無視して UTC の日付と時刻と解釈してしまうのです。
本番環境で事故を起こす前に気づけてよかったですが、かなり危険な挙動です。こういった挙動をする可能性を踏まえても、Node.js のサーバーサイドの時刻は UTC を使っておいて損はないのかなと思います。
↓まだ議論中のようですが早く解決されることを祈っています。。。
https://github.com/prisma/prisma/issues/5051

ライブラリを使えば解決できるのか

日付を扱うライブラリとして dayjs や date-fns が有名どころとしてありますが、結論これらを使っても上述の問題はある程度解決してくれますが、Date 型が必要になった瞬間あまり意味がなくなってしまいます。

タイムゾーンなしの文字列は同様の挙動

同様に、タイムゾーンがない文字列の場合は、現在のタイムゾーンとして初期化されてしまいます。

> const dayjs = require("dayjs")

// JST の場合
> dayjs("2022-08-21T10:00:00").format("HH:mm:ss")
'10:00:00'
> dayjs("2022-08-21T10:00:00").toISOString()
'2022-08-21T01:00:00.000Z'

// UTC の場合
> dayjs("2022-08-21T10:00:00").format("HH:mm:ss")
'10:00:00'
> dayjs("2022-08-21T10:00:00").toISOString()
'2022-08-21T10:00:00.000Z'

toDate をすると結局はシステムのタイムゾーン

例えば dayjs の場合、タイムゾーンを指定して dayjs オブジェクトを初期化することができますが、Date オブジェクトに変換した瞬間同様に、システムのタイムゾーンに強制されてしまいます。
日付系のライブラリを使ったとしても Date の挙動までは変えられないことに注意しましょう。

> const tz = require("dayjs/plugin/timezone")
> const utc = require("dayjs/plugin/utc")
> dayjs.extend(tz)
> dayjs.extend(utc)

// UTC の場合
> dayjs().tz("Europe/London").format("HH:mm:ss")
'15:03:07'
> dayjs().tz("Europe/London").toDate().toString()
'Sun Aug 21 2022 14:03:15 GMT+0000 (GMT)'

// JST の場合
> dayjs().tz("Europe/London").format("HH:mm:ss")
'15:06:35'
> dayjs().tz("Europe/London").toDate().toString()
'Sun Aug 21 2022 23:06:36 GMT+0900 (日本標準時)'

まとめ

繰返しになりますが、以下の点に注意して JavaScript では日付を扱いましょう。

  • データのやり取りの際は必ずタイムゾーンを付与する(当たり前だがサボりがち)
  • 日付系ライブラリを使っても変換した瞬間 Date 型の制約には引っかかってしまう
  • データベースとやりとりするシステムではできるだけ UTC を使った方が安全である

Discussion