Temporal におけるタイムゾーンとサマータイム、曖昧性の解決

Table of Contents

Clock Time と Exact Time の違いを理解する

Temporal の中核となるコンセプトは、wall-clock timeローカル時間clock time とも呼ばれる、タイムゾーンに依存した時刻)と exact timeUTC 時間 とも呼ばれる、地球上のどこでも同じ時刻)を区別することです。

wall-clock time は地方政府によって制御されているため、突然変更される可能性があります。サマータイムが導入されたり、ある国のタイムゾーンが他のものに変更されたりすると、ローカル時間は即座に変更されます。exact time は変更されない国際的な定義を持っており、UTCという特別なタイムゾーンで呼ばれています。Wikipedia の定義によれば、

Coordinated Universal Time (UTC)は、世界が時計と時刻を調整、規制するための時間の基準です。これは、経度 0 度における平均太陽時から 1 秒以内のものであり、サマータイムによる調整は行われていません。これは実質的にグリニッジ標準時(GMT)の後継です。

すべての wall-clock time は UTC Offset によって定義されます。これは、特定の時計が UTC からどれだけ進んでいるか(または遅れているか)を表す時間の量です。例えば、カリフォルニアでの 2020 年 1 月 19 日の場合、UTC オフセット(以降オフセット)は-08:00 となります。これはサンフランシスコでの wall-clock time が UTC よりも 8 時間遅れていることを表しています。したがって、ローカルでの 10:00 は UTC では 18:00 です。しかし、同じ時にインドでは +05:30 というオフセットの local time を持っています。

ISO 8601RFC 3339は特定の時刻と時間を表現するための標準規格です。例:2020-09-06T17:35:24.485Z。ここで Z は UTC を表す接尾辞です。

Temporal は exact time を表す 2 つのタイプを持っています。Temporal.Instantは exact time だけを持ち、他の情報を持ちません。Temporal.ZonedDateTimeは exact time の他にタイムゾーンやカレンダーシステムの情報を持ちます。

exact time を表す他の方法は、Unix epoch(1970 年 1 月 1 日の真夜中)からの差分を数値で表すことです。例えば、Temporal.Instant(exact-time タイプ)は epoch からの経過ナノ秒を表すBigIntで構成できます。

タイムゾーンオフセットの変更とサマータイムを理解する

タイムゾーン は UTC と wall-clock がどのように関連しているかを定義します。タイムゾーンは、exact time を受け取って UTC オフセットを返す関数、および反対方向への変換に対応する関数と考えられます。(なぜ exact -> local の変換は 1 対 1 なのに、local -> exact への変換は曖昧なのかの理由は後述します)。

Temporal は IANA Time Zone Database(TZ database とも呼ばれる)を使用します。これは、タイムゾーン関数の世界的なリポジトリであると考えられます。各 IANA タイムゾーンは以下のものを持ちます:

TZ データベースは世界の政治的な変化によって年に数回アップデートされます。各アップデートにはタイムゾーン定義の変更が含まれます。これらの変更は通常は未来の date/time 値にのみ影響するものです。しかし、ごくまれに過去の範囲が修正されることもあります。例えば、20 世紀初頭の計時に関する新たな歴史的資料が発見された場合などです。

Temporal における Wall-Clock Time、Exact Time、タイムゾーン

Temporal では:

exact time を格納する Temporal タイプから、人間が読みやすいカレンダー上の日付を得る方法は 2 つあります:

instant = Temporal.Instant.from('2019-09-03T08:34:05Z');
formatOptions = {
  era: 'short',
  year: 'numeric',
  month: 'short',
  day: 'numeric',
  hour: 'numeric',
  minute: 'numeric',
  second: 'numeric'
};

zdt = instant.toZonedDateTimeISO('Asia/Tokyo');
  // => 2019-09-03T17:34:05+09:00[Asia/Tokyo]
zdt.toLocaleString('en-us', { ...formatOptions, calendar: zdt.calendar });
  // => 'Sep 3, 2019 AD, 5:34:05 PM'
zdt.year;
  // => 2019
zdt = instant.toZonedDateTime({timeZone: 'Asia/Tokyo', calendar: 'iso8601'}).toLocaleString('ja-jp', formatOptions);
  // => '西暦2019年9月3日 17:34:05'

zdt = instant.toZonedDateTime({timeZone: 'Asia/Tokyo', calendar: 'japanese'});
  // => 2019-09-03T17:34:05+09:00[Asia/Tokyo][u-ca=japanese]
zdt.toLocaleString('en-us', { ...formatOptions, calendar: zdt.calendar });
  // => 'Sep 3, 1 Reiwa, 5:34:05 PM'
zdt.eraYear;
  // => 1

また、カレンダー上の日付や wall-clock time を exact time に変換することもできます:

// タイムゾーンを与えることで様々なlocal timeをexact timeに変換する
date = Temporal.PlainDate.from('2019-12-17');
// もし時刻が省略されたら、デフォルトでその日の最初の時刻になる
zdt = date.toZonedDateTime('Asia/Tokyo');
  // => 2019-12-17T00:00:00+09:00[Asia/Tokyo]
zdt = date.toZonedDateTime({ timeZone: 'Asia/Tokyo', plainTime: '10:00' });
  // => 2019-12-17T10:00:00+09:00[Asia/Tokyo]
time = Temporal.PlainTime.from('14:35');
zdt = time.toZonedDateTime({ timeZone: 'Asia/Tokyo', plainDate: Temporal.PlainDate.from('2020-08-27') });
  // => 2020-08-27T14:35:00+09:00[Asia/Tokyo]
dateTime = Temporal.PlainDateTime.from('2019-12-17T07:48');
zdt = dateTime.toZonedDateTime('Asia/Tokyo');
  // => 2019-12-17T07:48:00+09:00[Asia/Tokyo]

// exact timeから、UNIX epochからの経過秒、ミリ秒、ナノ秒を得る
inst = zdt.toInstant();
epochNano = inst.epochNanoseconds; // => 1576536480000000000n
epochMilli = inst.epochMilliseconds; // => 1576536480000
epochSecs = inst.epochSeconds; // => 1576536480

タイムゾーンオフセットの変更や DST による曖昧性

通常、タイムゾーンの定義は、「local date / clock time」と「UTC date / time」を 1 対 1 に対応付けます。

しかし、タイムゾーンの変更される付近では、 時間の曖昧性 が発生する場合があります。ここでは、wall-clock time を exact time に変換するためにどのオフセットを使えばいいかが明確ではありません。この曖昧性によって、1 つの UTC time に対して 2 つの clock time が発生します。

いずれのケースにせよ、local time と exact time に変換する際の曖昧性を解決するためには、ありうる 2 つのオフセットのどちらかを使用するか、エラーを発せさせる決定をする必要があります。

Temporal における日時の曖昧性の解決

Temporalでは、もし exact time またはタイムゾーンがわかっているなら、日時の曖昧性は発生しません。例:

// ソースがUTCにおけるexact timeなので曖昧性は発生しない
inst = Temporal.Instant.from('2020-09-06T17:35:24.485Z');
  // => 2020-09-06T17:35:24.485Z
// オフセットを用いることでlocal timeを曖昧さを排除して"正確"にできる
inst = Temporal.Instant.from('2020-09-06T10:35:24.485-07:00');
  // => 2020-09-06T17:35:24.485Z
zdt = Temporal.ZonedDateTime.from('2020-09-06T10:35:24.485-07:00[America/Los_Angeles]');
  // => 2020-09-06T10:35:24.485-07:00[America/Los_Angeles]
// もし、ソースがexactなTemporalオブジェクトなら、曖昧性は発生しない
zdt = inst.toZonedDateTimeISO('America/Los_Angeles');
  // => 2020-09-06T10:35:24.485-07:00[America/Los_Angeles]
inst2 = zdt.toInstant();
  // => 2020-09-06T17:35:24.485Z

正確でないソースから exact time(Temporal.ZonedDateTimeTemporal.Instant) を作成する場合に曖昧性が発生する場合があります。例えば:

// オフセットがわからないので曖昧になる可能性があります!
zdt = Temporal.PlainDate.from('2019-02-19').toZonedDateTime('America/Sao_Paulo'); // 曖昧
zdt = Temporal.PlainDateTime.from('2019-02-19T00:00').toZonedDateTime('America/Sao_Paulo'); // 曖昧

// たとえソースの文字列からオフセットが与えられていても、タイプがexactではない場合はオフセットが無視され、曖昧になる可能性が出てきます。
dt = Temporal.PlainDateTime.from('2019-02-19T00:00-03:00');
zdt = dt.toZonedDateTime('America/Sao_Paulo'); // 曖昧

// オフセットは、オブジェクトをexactタイプからそうではないタイプへ変換する際に失われます
zdt = Temporal.ZonedDateTime.from('2020-11-01T01:30-08:00[America/Los_Angeles]');
  // => 2020-11-01T01:30:00-08:00[America/Los_Angeles]
dt = zdt.toPlainDateTime(); // オフセットが失われます!
  // => 2020-11-01T01:30:00
zdtAmbiguous = dt.toZonedDateTime('America/Los_Angeles'); // 曖昧
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles]
  // ここで、オフセットが-07:00であることに注意してください。これは"最初の"午前1時30分のものです
  // もともとの、"2回目の午前1時30分"を表す-08:00(太平洋標準時)ではなくなっています

これらの曖昧性を解決するために、Temporalの「exact 以外のタイプから exact タイプを生成するメソッド」はdisambiguation(曖昧性解消)オプションを受け付けられます。これは、変換元の時刻が曖昧な場合の処理を制御します:

既存のコードやサービスと相互運用する場合は、'compatible'がレガシーのDateおよび moment.js や Luxon、date-fns の動作と一致します。また、クロスプラットフォームの標準であるRFC 5545 (iCalendar)の挙動とも一致します。

このオプションは以下のメソッドに存在します。

例;サマータイムとdisambiguation曖昧性解消

この説明はmoment-timezone のドキュメントに対応しています。

サマータイムに入ると、時計が 1 時間進みます。しかし実際には、時刻ではなくオフセットが動いているのです。オフセットが正の方向に動くと、1 時間が魔法のように消えてなくなります。もし、あなたがコンピュータのデジタル時計を見ていたなら、あなたは時計が 1:58、1:59、3:00 と変化するのに気づくでしょう。オフセットを含めて考えると、実際に何が起こっているのかを簡単に理解できます。

1:58 -08:00
1:59 -08:00
3:00 -07:00
3:01 -07:00

結果として、1:59:59 から 3:00:00 までの時刻は発生しませんでした。この場合、'earlier'モードでは、変更後のオフセットが変更前から有効であったかのようにソース時刻を解釈して exact time を返却します。返される exact time はサマータイムのオフセット変化量(多くの場合 1 時間)だけ前になります。この場合、'later'モードでは、変更前のオフセットが変更後も継続するようにソース時刻を解釈して exact time を返却します。返される exact time はサマータイムのオフセット変化量だけ後になります。'compatible'モードでは、ここでは'later'と同じ結果を返します。これはDateを用いた既存の JavaScript コードの挙動と一致します。

// 春のはじめに時計が1時間進む場合のdisambiguation(曖昧性解消)モードの違い:ここで-07:00はサマータイム、-08:00は標準時を表す
props = { timeZone: 'America/Los_Angeles', year: 2020, month: 3, day: 8, hour: 2, minute: 30 };
zdt = Temporal.ZonedDateTime.from(props, { disambiguation: 'compatible' });
  // => 2020-03-08T03:30:00-07:00[America/Los_Angeles]
zdt = Temporal.ZonedDateTime.from(props);
  // => 2020-03-08T03:30:00-07:00[America/Los_Angeles]
  // (デフォルトでは'compatible'モード)
earlier = Temporal.ZonedDateTime.from(props, { disambiguation: 'earlier' });
  // => 2020-03-08T01:30:00-08:00[America/Los_Angeles]
  // (1:30 clock time; ここではすでにStandard Timeになっていたとみなされる)
later = Temporal.ZonedDateTime.from(props, { disambiguation: 'later' });
  // => 2020-03-08T03:30:00-07:00[America/Los_Angeles]
  // (この例では'later'は'compatible'と同じ挙動)
later.toPlainDateTime().since(earlier.toPlainDateTime());
  // => PT2H
  // (clock time上では2時間の差があるように見える...
later.since(earlier);
  // => PT1H
  // ... しかし、実際の時刻では1時間の差しかない)

同様に、サマータイムの終わりには、時計が 1 時間巻き戻ります。

この場合、同じ 1 時間が魔法のように繰り返されます。'earlier'モードでは、exact time は、2 つの wall-clock time のうち前のものになり、'earlier'モードでは後のものになります。この例では、'compatible'モードの挙動は'earlier'モードと同じになります。これはレガシーなDateを使った既存の JavaScript コードの挙動と同じになります。

// 秋のおわりに1時間が繰り返される場合のdisambiguation(曖昧性解消)モードの違い:ここで、-07:00はサマータイム、-08:00は標準時を表す
props = { timeZone: 'America/Los_Angeles', year: 2020, month: 11, day: 1, hour: 1, minute: 30 };
zdt = Temporal.ZonedDateTime.from(props, { disambiguation: 'compatible' });
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles]
zdt = Temporal.ZonedDateTime.from(props);
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles]
  // デフォルトでは'compatible'モード.
earlier = Temporal.ZonedDateTime.from(props, { disambiguation: 'earlier' });
  // => 2020-11-01T01:30:00-07:00[America/Los_Angeles]
  // この例では、'earlier'モードは'compatible'モードと同じ挙動
later = Temporal.ZonedDateTime.from(props, { disambiguation: 'later' });
  // => 2020-11-01T01:30:00-08:00[America/Los_Angeles]
  // 同じclock timeだが一時間遅い結果が返る
later.toPlainDateTime().since(earlier.toPlainDateTime());
  // => PT0S
  // (同じ缶のように見えるが...
later.since(earlier);
  // => PT1H
  // ... 実際は1時間の差がある

タイムゾーン定義が永続的に変更されることによる曖昧性の発生

タイムゾーンの定義は変更される可能性があります。ほとんどの場合、これらの変更は未来の日時に対する変更であり、過去に格納された過去の日時には影響がありません。しかし、コンピュータは時々未来に関するデータを格納することがあります!例えば、カレンダーアプリはユーザーがリマインドしてほしい友達の来年の誕生日を記録します。未来の日時データをオフセットとタイムゾーンと共に記録し、さらにタイムゾーンの定義が変更された場合、新しいタイムゾーン定義と古いタイムゾーン定義が衝突する可能性があります。このようなケースでは、衝突を解決するためにTemporal.ZonedDateTime.fromoffsetオプションが用いられます。

Temporal.ZonedDateTime.fromでは、デフォルトのオプションはrejectに設定されます。これは、デフォルトとすべき解決方法が明確ではないためです。その代わり、開発者は無効な日時データをどのように修正するのかを指定する必要があります。

Temporal.ZonedDateTime.withでは、デフォルトは'prefer'です。

このデフォルト値は、時刻のフィールドを変更した場合に発生するサマータイムの曖昧性を防ぐのに役立ちます。例えば、Temporal.ZonedDateTimeが「サマータイムによって午前 1 時〜2 時が繰り返される日の、"2 回目の"午前 1 時 30 分」に設定されていて、.with({minute: 45})をデフォルトのoffset: 'prefer'オプションで呼び出したとします。すると、曖昧性はデフォルトのオプションによって解決されます。なぜなら、現状のオフセットは新しい日時でも依然として有効であり、"2 回目の"午前 1 時 45 分が返されるからです。しかし、現状のオフセットが新しい日時で無効の場合(例:.with({hour: 0}))、デフォルトオプションはオフセットをタイムゾーンに合うように変更します。

このようなオフセットとタイムゾーンの衝突はTemporal.ZonedDateTimeでのみ考慮されることに注意してください。なぜなら、他のTemporalタイプは IANA タイムゾーンとタイムゾーンオフセットを同時に考慮することがないからです。例えば、Temporal.Instant.fromでは衝突は発生しません。なせなら、Temporal.Instantは入力されたタイムゾーンを無視し、常にオフセットを用いて日時を評価するからです。

例: offset オプション

offsetオプションを用いる一番の理由は、タイムゾーン定義が変更される前に保存された日時データを解釈することです。例えば、ブラジルは 2019 年にサマータイムを廃止し、サマータイムによる最後のオフセット変化は 2019 年 2 月 16 日に行われました。サマータイムを永続的に廃止することがアナウンスされたのは 2019 年の 4 月のことでした。ここで、2018 年(サマータイムの廃止がアナウンスされる前)に動いていたアプリが遠い未来の日時をオフセットと IANA タイムゾーンの両方の値を持つフォーマットとして保存していたと想像してみましょう。このようなフォーマットはTemporal.ZonedDateTime.prototype.toStringだけでなく、他のプラットフォームであるJava.time.ZonedDateTimeなどでも使用されます。

ここでは、記録された日時がサンパウロにおける 2020 年 1 月 15 日であると仮定しましょう。

zdt = Temporal.ZonedDateTime.from({ year: 2020, month: 1, day: 15, hour: 12, timeZone: 'America/Sao_Paulo' });
zdt.toString();
  // => '2020-01-15T12:00:00-02:00[America/Sao_Paulo]'
  // この文字列が外部のデータベースに格納されたと仮定します。
  // ここで、`-02:00`はサマータイムのオフセットを意味しています。

// また、上記のコードを今日実行すると、サマータイムが廃止されたことを反映して、`-03:00`というオフセットが返ることに注意してください。
// しかし、このコードは2018年に実行されたため、当時のブラジルのサマータイムにしたがって`-02:00`というオフセットが返りました。

この文字列はこれを生成して保存した 2018 年では有効なものでした。しかし、2019 年にタイムゾーンのルールが変更された後では、2020-01-15T12:00-02:00[America/Sao_Paulo]は、タイムゾーンオフセットが-03:00となるため無効です。この文字列を現行のタイムゾーンのルールでパースする際は、Temporalはこれをどのように解釈すればいいかを知る必要があります。offsetオプションは、このようなケースにおいて役に立ちます。

savedUsingOldTzDefinition = '2020-01-01T12:00-02:00[America/Sao_Paulo]'; // 昔に保存された文字列
/* 無効 */ zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition);
  // => RangeError: Offset is invalid for '2020-01-01T12:00' in 'America/Sao_Paulo'. Provided: -02:00, expected: -03:00.
  // デフォルトではオフセットとタイムゾーンが衝突した場合はエラーが投げられる
/* WRONG */ zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'reject' });
  // => RangeError: Offset is invalid for '2020-01-01T12:00' in 'America/Sao_Paulo'. Provided: -02:00, expected: -03:00.
zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'use' });
  // => 2020-01-01T11:00:00-03:00[America/Sao_Paulo]
  // 古いオフセットを用いて入力日時を解釈する。UTC時間は変わらないが、local timeが11:00に変更される。
zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'ignore' });
  // => 2020-01-01T12:00:00-03:00[America/Sao_Paulo]
  // 現行のタイムゾーンのルールにしたがって入力日時を解釈する。古いオフセットは無視される。
zdt = Temporal.ZonedDateTime.from(savedUsingOldTzDefinition, { offset: 'prefer' });
  // => 2020-01-01T12:00:00-03:00[America/Sao_Paulo]
  // 現行のタイムゾーンでは古いオフセットが無効なので、現行のルールから新たにオフセットを計算してそれを使用する。