JSで「東京の10時」を他都市に変換する — オフセットを引き算すると壊れる
タイムゾーン変換ツールを作った。「2026-07-20 10:00(東京)」と入力したら、東京の行に 2026-07-19 16:00 と出た。18時間ずれている。
面白いのは、他の都市との相対関係は全部合っていたことだ。UTCとの差は UTC+09:00、ロンドンは夏時間扱い、ニューヨークは UTC-04:00。オフセットの計算は正しく、基準となる瞬間そのものがずれていた。
何をしようとしていたか
やりたいのは「あるタイムゾーンでの壁時計時刻」から「実際の瞬間(Date)」を求めることだ。逆方向(Date → 各地の表示)は Intl.DateTimeFormat に timeZone を渡すだけで済むが、こちらは標準APIが用意されていない。
まず、指定タイムゾーンでの壁時計時刻を「UTCだと思って」ミリ秒に直すヘルパを用意する。
1 | |
asUTCms(d, "Asia/Tokyo") - d.getTime() は、その瞬間の東京のオフセット(+9時間ぶんのミリ秒)になる。ここまでは定番の手筋だ。
壊れていたコード
問題はここから。目的の壁時計時刻を一度UTCとして解釈し、そこからオフセットを引けば求める瞬間になる。ただしオフセット自体が日時に依存する(夏時間の境界)ので、収束するまで数回まわす、という設計にした。
1 | |
東京で追ってみる。
| 回 | guess | diff(=その時点のオフセット) | 次の guess |
|---|---|---|---|
| 初期 | 07-20T10:00Z | — | — |
| 1 | 07-20T10:00Z | +9h | 07-20T01:00Z ← 正解 |
| 2 | 07-20T01:00Z | +9h | 07-19T16:00Z ← 行き過ぎ |
| 3 | 07-19T16:00Z | +9h | 07-19T07:00Z |
1回目で正解に着いている。ところが diff は「guess の時点でのゾーンオフセット」であって「あとどれだけずれているか」ではない。東京のオフセットは常に+9時間なので、diff は永遠に0にならず、ループは毎回9時間ずつ引き続ける。
if (diff === 0) break; という終了条件が、この誤解をそのまま表している。オフセットが0になる場所(UTC)でしか止まらない。実際、UTCの行だけは正しい値が出ていた。
直し方
補正は 毎回 target から引き直す。guess から引いてはいけない。
1 | |
終了条件も「オフセットが0」ではなく「前回と同じ答えに落ち着いた」に変わる。これが本来の不動点反復だ。
ループが2回以上必要になるのは、1回目の推定値と最終的な答えが夏時間の境界をまたぐ場合だけだ。たとえば3月の切り替え直後の時刻を指定すると、初期推定は切り替え前のオフセットを拾い、補正後に切り替え後へ移る。そこでもう一度オフセットを取り直して収束する。ほとんどの入力は1回で終わる。
確認した入力
直したあと、性質の違う2つで確かめた。
東京 2026-07-20 10:00(日本は夏時間なし)
1 | |
ニューヨーク 2026-01-15 09:00(冬なので夏時間なし、オフセットが夏と変わる)
1 | |
夏のニューヨークは -04:00、冬は -05:00。同じ都市でも日付でオフセットが変わることと、その差が変換結果に効いていることが両方確認できる。
学び
この手のバグは相対値が全部正しいのに絶対値だけずれるという形で出る。オフセット表示や夏時間の判定といった「周辺」は合っているので、パッと見では正しそうに見えてしまう。だから、変換した結果を入力と同じタイムゾーンで表示し直して、入力値と一致するかを確かめるのがいちばん手っ取り早い検算になる。今回も、東京と入力して東京の行を見た瞬間に露見した。
もうひとつ。「収束するまでループ」と書くときは、終了条件が本当にその収束を表しているかを疑ったほうがいい。diff === 0 は一見もっともらしいが、diff の定義が「残差」ではなく「オフセット」だったので、条件が意味をなしていなかった。ループの終了条件は、ループ内で計算している量の意味と揃っている必要がある。
なお、この面倒を丸ごと回避したいなら Temporal API の Temporal.ZonedDateTime が答えになる。対応環境が揃うまでは、上の不動点反復が Intl だけで完結する現実解だ。
できあがったツールは タイムゾーン変換 に置いてある。世界19都市の現地時刻を一括表示し、夏時間の適用有無もバッジで出る。処理は全部ブラウザ内で完結する。同じ流れで作った 文字コードダンプ もあわせてどうぞ。