UNIXタイムスタンプ ⇔ 日時の変換ツールは「new Date(ts * 1000) して toLocaleString() するだけ」に見える。だが実際に作ると、タイムゾーンの扱いで静かにバグる。特に「日時 → タイムスタンプ」方向で、<input type="datetime-local"> の値をそのまま new Date() に渡すと、ツールを動かす環境によって結果が変わってしまう。
UNIXタイムスタンプと日時を相互変換するツールをぱんだツールズの1機能として作った。秒/ミリ秒・JST/UTC を扱う。
この記事では、タイムスタンプ変換で踏むタイムゾーンの罠と、それを Intl.DateTimeFormat と明示的オフセットで回避する実装を解説する。
罠1:datetime-local は「ローカルタイム」として解釈される
一番危ないのが「日時 → タイムスタンプ」方向。<input type="datetime-local"> が返す値は 2024-03-28T00:00(オフセット情報なし)のような文字列。これを素直に new Date() に渡すと——
new Date('2024-03-28T00:00:00')
// → 実行環境のローカルタイムゾーンで解釈される
オフセットのない日時文字列は、ブラウザが実行マシンのタイムゾーンで解釈する。つまり、同じ入力でも日本の利用者と海外の利用者で違うタイムスタンプが出る。「JSTで2024/3/28 0:00のつもり」が、UTC環境では9時間ズレた値になる。サーバーサイドレンダリングや海外ユーザーを考えると、これは確実に踏むバグ。
対策はシンプルで、入力をどのタイムゾーンとして扱うかをオフセット文字列で明示する。ユーザーが JST を選んだら +09:00 を、UTC を選んだら Z を末尾に付けてからパースする。
let date: Date
if (dtTimezone === 'JST') {
// datetime-local の値はローカル解釈なので、JST と明示するため +09:00 を付与
date = new Date(`${dtInput}:00+09:00`)
} else {
date = new Date(`${dtInput}:00Z`) // Z = UTC
}
if (!Number.isFinite(date.getTime())) {
setDtError('有効な日時を入力してください')
return
}
const ms = date.getTime()
const sec = Math.floor(ms / 1000)
+09:00 や Z を付けると、new Date() はそのオフセットで解釈するので、実行環境のタイムゾーンに依存しなくなる。これで「どこで動かしても同じ入力は同じタイムスタンプ」になる。getTime() は常に UTC 基準のミリ秒を返すので、1000 で割って Math.floor すれば秒のタイムスタンプ。
罠2:表示の +9時間を自分で計算しない
逆方向(タイムスタンプ → 日時)でも、JST 表示のために「UTC に9時間足す」を手計算したくなる。が、それは Intl.DateTimeFormat に任せた方が安全で正確。
function formatDatetime(date: Date, timezone: Timezone): string {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
timeZone: timezone === 'JST' ? 'Asia/Tokyo' : 'UTC',
}
const parts = new Intl.DateTimeFormat('ja-JP', options).formatToParts(date)
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
return `${get('year')}年${get('month')}月${get('day')}日 ${get('hour')}:${get('minute')}:${get('second')} ${timezone}`
}
timeZone: 'Asia/Tokyo' を指定すれば、Intl が IANA タイムゾーンデータベースに基づいて正しい現地時刻に変換してくれる。Date オブジェクト自体は UTC のある一点(エポックからの経過ミリ秒)でしかなく、それを「どのタイムゾーンの壁掛け時計で読むか」を timeZone で切り替える、という考え方。
手で date.getTime() + 9 * 3600 * 1000 のように足す実装は、一見動くが筋が悪い。サマータイム(DST)のある地域だと固定オフセットが破綻するし、getHours() 等のローカルメソッドと混ぜると二重にズレる。Asia/Tokyo は DST がないので今回は固定 +9 でも結果は合うが、「オフセットを手で持たない」を癖にしておく方が、他のタイムゾーンに広げたときに事故らない。
formatToParts を使っているのは、format() が返すロケール依存の文字列(区切り文字がブラウザで揺れる)に振り回されず、年・月・日・時・分・秒を部品単位で取り出して自前のフォーマットに組むため。2024年03月28日 09:00:00 JST のような体裁を確実に作れる。
罠3:秒とミリ秒、そして Date の有効範囲
UNIXタイムスタンプは文脈によって秒だったりミリ秒だったりする。JavaScript の Date.now() や getTime() はミリ秒だが、多くの API やデータベース(Unix time)は秒。1000倍の違いを取り違えると、1970年付近の日時や遠い未来が出て「あれ?」となる。
このツールは単位をユーザーに選ばせ、内部では常にミリ秒に正規化してから new Date() に渡す。
const num = Number(trimmed)
if (!Number.isFinite(num)) {
setTsError('有効な数値を入力してください')
return
}
const ms = tsUnit === 'seconds' ? num * 1000 : num
// Date が扱える範囲を超えていないかチェック
if (ms < -8640000000000000 || ms > 8640000000000000) {
setTsError('対応範囲外のタイムスタンプです')
return
}
const date = new Date(ms)
最後の ±8640000000000000 は、JavaScript の Date が表現できる限界値。Date はエポックの前後それぞれ1億日(= 8,640,000,000,000,000ミリ秒)までしか扱えず、それを超えると Invalid Date になる。ミリ秒のつもりで巨大な値を入れたり、桁を間違えたりするとここに当たるので、new Date() に渡す前に範囲チェックしてエラーを返す。Number.isFinite で数値以外(空文字や NaN)も先に弾いておく。
まとめ
タイムスタンプ変換は「掛けて割るだけ」に見えて、タイムゾーンと範囲で地味に事故る。
-
datetime-localの値はオフセットなし=実行環境のローカルタイムで解釈される。+09:00/Zを明示してパースし、環境非依存にする - 表示は
Intl.DateTimeFormatのtimeZoneに任せる。+9時間を手で足さない(DST・二重補正の事故を防ぐ) -
formatToPartsで部品を取り、ロケール依存の区切りに振り回されず自前体裁を組む - 秒/ミリ秒は内部でミリ秒に正規化。
Dateの有効範囲±8640000000000000ミリ秒を超えないか事前チェック
Date は「UTC上の一点」で、タイムゾーンは「それをどう読むか」でしかない——この分離を意識すると、オフセットを手で持つ実装の危うさが見えてくる。タイムゾーンは横着すると必ず祟る領域だった。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。