⏱️

ECMAScript の Date コンストラクタにミリ秒を超える精度の日付文字列を与えた際の挙動は実装依存である

2022/12/18に公開

はじめに

ナノ秒精度の RFC3339 文字列を Web ブラウザの JavaScript で扱っていて予想外の挙動をするケースがありました。

一部のブラウザ (確認した範囲では WebKit のみ) では以下の挙動をします。

new Date("2022-12-31T23:59:59.999999999Z").toISOString()
// => "2023-01-01T00:00:00.000Z"

冷静に考えると Date はミリ秒精度を扱うので、それを超える精度を扱おうとした際に予想外の挙動になるのは当然とも言えますが、この件について仕様を確認した結果などを書き残しておきます。

Date コンストラクタに与える日付文字列の仕様

MDN の JavaScript リファレンスにおける Date() コンストラクター のページには、引数に日付文字列を与えた際の挙動について記載されていました。

日付を表す文字列値で、Date.parse() メソッドによって認識される形式で指定します。(ECMA262 仕様書は ISO 8601 の簡易版を定めています

ISO 8601 の簡易版 の部分は ECMA262 の 21.4.1.18 Date Time String Format の項にリンクされています。そこには以下の記述があります。

ECMAScript defines a string interchange format for date-times based upon a simplification of the ISO 8601 calendar date extended format. The format is as follows: YYYY-MM-DDTHH:mm:ss.sssZ

フォーマットの sss は、以下の通り3桁のミリ秒と明記されています。

sss is the number of complete milliseconds since the start of the second as three decimal digits.

MDN に戻ると、この形式以外の文字列を指定した場合には挙動が実装依存であることが注意点として記載されていました。

メモ: Date コンストラクター(および Date.parse と同等)で日付文字列を解釈する際には、常に入力が ISO 8601 形式 (YYYY-MM-DDTHH:mm:ss.sssZ) であることを確認してください。他の形式で解釈した場合には、その挙動は実装によって定義されていて、すべてのブラウザーで動くとは限りません。

改めて何が起きるのか

この記事を書いている現在、私が確認した範囲では、WebKit エンジンを利用したブラウザで Date コンストラクタに与える日付文字列の秒未満の部分が一定の値を超えると、ミリ秒が切り上がります。

new Date("2023-01-01T00:00:00.00099987792968749989Z").toISOString()
// => "2023-01-01T00:00:00.000Z"

new Date("2023-01-01T00:00:00.0009998779296874999Z").toISOString()
// => "2023-01-01T00:00:00.001Z"

原因の考察

なぜこんなことになるのか考えてみたのですが、UNIX 時刻(ミリ秒)として number 型で扱った場合の挙動と非常に似ていました。

1672531200000.99987792968749989
// => 1672531200000.9998

1672531200000.9998779296875
// => 1672531200001

ここからは推測ですが、おそらく倍精度浮動小数点の精度に起因するものと考えられます。

実際に WebKit のソースコードでそれらしい箇所を探してみたのですが、ミリ秒以下をすべてパースして浮動小数点にしているっぽいコードでした(C++ わからないので雰囲気読みです)。

https://github.com/WebKit/WebKit/blob/7f8bed6e6004b6fb581b706db574933747d4abfa/Source/WTF/wtf/DateMath.cpp#L556-L572

気づいたきっかけ

同じ問題に直面する人がいるかもしれないので、そもそもなんでこんな事に気づいたのか、についても書き残しておきます。

バックエンドの API サーバーを Go で書いていました。Go で日時を扱う型としては標準ライブラリの time.Time 型があります。この型は標準で JSON のシリアライズ/デシリアライズにおいて RFC3339 形式を扱います。

b, _ := json.Marshal(time.Now())
fmt.Println(string(b))
// => "2022-12-18T14:42:59.068515+09:00"

var t time.Time
json.Unmarshal(b, &t)
fmt.Println(t)
// => 2022-12-18 14:42:59.068515 +0900 JST

これはフロントエンド側の Date 型との相互変換を行う上ではとても便利で、JavaScript の Date インスタンスはそのまま JSON.stringify を使えば ISO8601 形式になり Go の time.Time 型にデシリアライズ可能で、Go でシリアライズした RFC3339 形式は Date コンストラクタに渡せばそのままパース可能……と、思っていました。

ところで、"期限" のような値を扱う場合、フロントエンドではユーザーに見せる上で勘違いを生んだりトラブルに発展しないよう 2022年12月31日 23:59 といったギリギリの日時を表示する一方、バックエンドではバリデーションや永続化層の精度などの問題から 2023-01-01T00:00:00Z といったキリの良い時刻で取り扱いたいケースがあります。

このとき、バックエンドの Go では元の日時から -1 ナノ秒した値を返していました。

t := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
t = t.Add(-1)
b, _ := json.Marshal(t)
fmt.Println(string(b))
// => "2022-12-31T23:59:59.999999999Z"

この値を Date コンストラクタの引数に渡したことで今回の問題に直面しました。

Go と JavaScript の日時型で扱える精度が違うことは理解していたつもりでしたが、こういった結果になるのは予想外でした。正確に扱うには、精度に合わせて UNIX 時間ミリ秒か、ミリ秒精度で定義したフォーマットでシリアライズするべきなのでしょう。

余談

現在 TC39 では次世代の日付 API 仕様として Temporal が提案されています。

https://github.com/tc39/proposal-temporal

さくっと試すにはドキュメントの Cookbook のページに polyfill が入っているので便利です。
https://tc39.es/proposal-temporal/docs/cookbook.html

Temporal ではナノ秒精度を扱うようです。こちらであれば今回の問題も発生しなくなりそうですね。
一方、Date 型との変換では同じ問題が引き続き発生すると考えられます。実際に Cookbook には、Temporal 型から Date 型に変換するためにはミリ秒を使えとあります。

// To convert Instant to legacy Date, use the epochMilliseconds property.

const instant = Temporal.Instant.from('2020-01-01T00:00:01.000999Z');
const result = new Date(instant.epochMilliseconds);

assert.equal(result.getTime(), 1577836801000); // ms since Unix epoch
assert.equal(result.toISOString(), '2020-01-01T00:00:01.000Z');

引き続き精度には気を使っていきたいと思います。

Discussion