はじめに
Intl.DateTimeFormat を使う際に、指定したフォーマットが無視されて、想定外の表示になることがあります。この記事では、そのような落とし穴について扱います。
Intl.DateTimeFormat について
を見ると分かるように、Intl.DateTimeFormat は、与えられた locale やフォーマットに従って日時を文字列に変換します。
format や formatToParts というメソッドで、Date オブジェクトをフォーマットされた文字列に変換することができます。
落とし穴の具体例
const date = new Date(2000, 1, 2, 3, 4, 5);
const formatter = Intl.DateTimeFormat('ja-JP', {
minute: 'numeric',
second: 'numeric'
});
console.log(formatter.format(date));
とすると、4:5 のような表示を期待しますが、おそらくほとんどの環境では、04:05 と表示されます。
console.log(formatter.resolvedOptions());
を見てみると、
{
"locale": "ja-JP",
"calendar": "gregory",
"numberingSystem": "latn",
"timeZone": "Asia/Tokyo",
"minute": "2-digit",
"second": "2-digit"
}
となっており、numeric の指定が無視されていることが分かります。
何故このようなことになるのでしょうか?
formatMatcher について
Intl.DateTimeFormat のコンストラクタには、formatMatcher というオプションがあります。
このオプションは、指定したフォーマットを、実際に使用可能なフォーマットにマッチングさせる際のアルゴリズムを選択します。
formatMatcher には以下のいずれかの値を設定することができます。
-
best fit: 実装依存で、basicと同等か、それよりも最適と判断したフォーマットを選択 (デフォルト) -
basic: 仕様として定義されているBasicFormatMatcherを利用
上記の通り、best fit は最適と判断されるものを選ぶというアルゴリズムで、実装に裁量がゆだねられています。
一方で、basic は仕様で明確に定義されているアルゴリズムです。
そこで、デフォルトの best fit ではなく、basic を利用して再度試してみます。
const formatter = Intl.DateTimeFormat('ja-JP', {
formatMatcher: 'basic',
minute: 'numeric',
second: 'numeric'
});
console.log(formatter.resolvedOptions());
{
"locale": "ja-JP",
"calendar": "gregory",
"numberingSystem": "latn",
"timeZone": "Asia/Tokyo",
"minute": "2-digit",
"second": "2-digit"
}
残念ながら、変化はありませんでした。
Intl.DateTimeFormat の仕様について
internationalization API の仕様である、ECMA-402 を確認してみます。
まずは、BasicFormatMatcher の仕様を見てみます。
簡単に言うと、利用可能なフォーマットと、指定されたフォーマットオプションの近さをスコアとして算出し、最も良いスコアであった利用可能なフォーマットを返すアルゴリズムです。
重要なのは、利用可能なフォーマットのリストの中から返している、ということです。
そこで、BasicFormatMatcher や BestFormatMatcher に渡しているフォーマットのリストについても見てみます。
f. Let formats be resolvedLocaleData.[[formats]].[[<resolvedCalendar>]]. g. If formatMatcher is "basic", then i. Let bestFormat be BasicFormatMatcher(formatOptions, formats). h. Else, i. Let bestFormat be BestFitFormatMatcher(formatOptions, formats).
上記では、formatMatcher に渡される formats は resolvedLocaleData.[[formats]].[[<resolvedCalendar>]] から取得されることが示されています。
つまり、2-digit にされてしまうのは、利用可能なフォーマットのリストに m:s が存在しないからです。
これはおそらく、m:s という表記が一般的ではないから、という理由であると推測されます。
とはいえ、日本語であれば m分s秒 というようなものは存在していても良い気はしますが…
resolvedLocaleData.[[formats]].[[<resolvedCalendar>]] は、実装定義のもので、これより深く見ようと思うと、各実装を実際に見ていく必要があるので割愛しています。
また、https://tc39.es/ecma402/#sec-intl.datetimeformat-internal-slots の NOTE 3 によると、実装は基本的に CLDR というリポジトリのデータに準拠することを推奨しているので、こちらを見てみるのも良いと思います。
まとめ
仕様では、BasicFormatMatcher や BestFormatMatcher が利用可能なフォーマットのリストの中からベストマッチを選ぶと定義されています。
このリストは実装定義ですが、通常は CLDR というリポジトリに基づいています。
そのため、ユーザーが指定した m:s という組み合わせが利用可能なフォーマットに存在しない場合、マッチングアルゴリズムは mm:ss を最適なものとして選択し、両方とも 2-digit に変更されてしまいます。
回避策
上記のような理由で numeric が 2-digit に変更されてしまうわけですが、このような変更を避けたい場合、いくつかの方法が考えられます。
例えば、parseInt のような処理を挟んだり、getMinutes や getSeconds を代わりに使ったりするのが挙げられます。
基本的には後者で十分だと思いますが、特殊なタイムゾーンで Date オブジェクトをフォーマットしたいような場合は前者にする必要があるでしょう。