Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

次世代JS標準時刻API Temporal を3年先行利用して得た知見を共有します!

ミラティブでソフトウェアエンジニアをしている @8beeeaaat です。 去る2025/9/6に札幌で開催されたフロントエンドカンファレンス北海道2025 にて発表した内容を再構成して公開します!

Dateに代わる新たな日時表現・操作APIとしてECMAScript標準化を目指し、2017年にプロポーザルが投稿された Temporal。ついに仕様策定も大詰めとなり、Firefox 139での正式実装リリースを始め、各ブラウザ・JSエンジンへの実装も進められています。

ミラティブでは2022年よりPolyfillを導入して社内業務管理機能を中心に導入を進めてきました。本記事では3年間の業務利用の中で培った業務で利用できるノウハウを共有します!

採用決定の現場

Date型の課題

従来のJavaScriptのDate型は、以下のような設計上の課題を抱えています。

  • mutableでバグを生みやすい
    • 同じインスタンスを使い回すと思わぬ副作用が発生することがある
  • 精度がミリ秒単位なので、より高い精度が求められるユースケースには不向き
  • 複雑な日時計算に不向きで、date-fns等へのライブラリ依存が増える
  • タイムゾーン・時差の取り扱い管理が難しい
    • ユーザーとシステムのタイムゾーンの違いに注意が必要
    • Date オブジェクトは常にシステムまたは環境のタイムゾーンで評価され、任意のタイムゾーンを完全に保持することができない
    • Day.js等のラッパーを使っても、Dateに戻すとタイムゾーンが上書きされる(参考
    • バックエンド連携時に toISOString でISO8601フォーマットで文字列として扱うことで時差(オフセット)は表現可能。一方でタイムゾーンは含まれないため、フロントエンド側での考慮が必要。
  new Date("2025-09-01T12:34:56.789-04:00") // 大西洋標準時を扱いたいが表現できない
  => Tue Sep 02 2025 01:34:56 GMT+0900 (日本標準時) //実行環境のタイムゾーンで扱われる

Dateのよくある落とし穴にはこんなものもあります

  • みんなが悩んだ Month+1 問題
const date = new Date(2025, 9, 1) // 9月1日を表現したかった
=> Wed Oct 01 2025 00:00:00 GMT+0900 //おや...

date.getMonth()
=> 9 //実は10月
  • 存在しない日付も未検証で処理される
new Date(2025,1,29) // 2025年2月29日は存在しない
=> Sun Mar 01 2025 00:00:00 GMT+0900

次世代時刻標準 Temporal

Temporalは、ECMAScript標準化を目指す新しい日時APIです。2025年5月にFirefox 139で正式実装され、今後他の主要ブラウザでも導入が進む見込みです(MDN Temporal)。

Firefoxではコンソールですぐ試せます

2025/09/08現在、当初のスコープを狭めたり調整しつつ TC39 Stage 3(仕様策定)→ Stage 4(標準化)への最終段階です。(GitHub Milestones)

TC39の2025年5月会議資料によるとV8もRustのライブラリを取り込みつつ実装を進めているようです。着実にTemporalの足音は近づいています!

Temporal標準化の足音

主な特徴

  • immutableで安全に扱える
  • 用途別に表現型を選択できる
    • タイムゾーン表現・操作にも対応(ZonedDateTime型)
  • ナノ秒精度で時刻を扱える
  • 日時操作あるあるな機能APIが組み込みで実装されている
    • 算術、比較、日付検証、期間の算出などなど

Polyfillによる利用

主要なPolyfillとして、@js-temporal/polyfilltemporal-polyfillがあり、既存環境でも導入可能です。(temporal-polyfillの方が軽量)

import { Temporal } from "temporal-polyfill";

const now = Temporal.Now.zonedDateTimeISO();
=> Temporal.ZonedDateTime 2025-09-06T01:23:45.678+09:00[Asia/Tokyo]

今日から始めるTemporal移行

ここではタイムゾーンを含む日時を扱う ZonedDateTime との相互変換を例にしています。Temporal自体は用途によって利用する表現型を選択する思想を打ち出していますので、実際に用いる場合は最適な表現型への変換を考慮すると良いでしょう。

Date型との相互変換

移行期に最もお世話になりそうなDate型との相互変換

// Date → Temporal.ZonedDateTime
export function dateToZdt(date: Date, timeZone: Temporal.TimeZoneLike = "Asia/Tokyo") {
  return Temporal.Instant.fromEpochMilliseconds(Number(date)).toZonedDateTimeISO(timeZone);
}

// Temporal.ZonedDateTime → Date
export function zdtToDate(t: Temporal.ZonedDateTime) {
  return new Date(t.epochMilliseconds);
}

RFC3339フォーマットとの相互変換

時差( OffsetDateTime )を含む表現であれば、RFC3339フォーマットの利用が便利です

// "2025-09-01T12:34:56.789Z" → Temporal.ZonedDateTime
export function rfc3339ToZdt(rfc3339: string, timeZone: Temporal.TimeZoneLike = "Asia/Tokyo") {
  return Temporal.Instant.from(rfc3339).toZonedDateTimeISO(timeZone);
}

// Temporal.ZonedDateTime → "2025-09-01T12:34:56.789+09:00"
export function zdtToRFC3339(t: Temporal.ZonedDateTime){
  return t.toString({ timeZoneName: "never", calendarName: "never" });
}

input["datetime-local"]との相互変換

HTMLフォームのdatetime-local型の ローカル日時文字列 の値を取り扱う際に活躍します

// "2025-09-01T12:34:56" → Temporal.ZonedDateTime
export function inputDateTimeToZdt(dateTimeString: string, timeZone: Temporal.TimeZoneLike = "Asia/Tokyo") {
  return Temporal.PlainDateTime.from(dateTimeString).toZonedDateTime(timeZone);
}

// Temporal.ZonedDateTime → "2025-09-01T12:34:56"
export function zdtToInputDateTime(t: Temporal.ZonedDateTime) {
  return t.toPlainDateTime().toString({ smallestUnit: "second" });
}

日本語日時表記

toLocaleString() を使って、日本語表記の日時文字列を生成します。 デフォルトだとこんな感じ。調整が必要そうです

const now = "2025-09-06T10:00:00+09:00[Asia/Tokyo]";
Temporal.ZonedDateTime.from(now).toLocaleString()
=> "2025/9/6 10:00:00 JST"

option引数に Intl.DateTimeFormat を加えて実務あるあるな日本語表記にします。

// Temporal.ZonedDateTime → "2025年9月6日(土) 10:00:00"
export function zdtToLocalizedDateTime(
  t: Temporal.ZonedDateTime,
  options?: Intl.DateTimeFormatOptions,
): string {
  return t.toLocaleString("ja-JP", {
    year: "numeric",
    month: "long",
    day: "numeric",
    weekday: "short",
    hour: "numeric",
    minute: "numeric",
    second: "numeric",
    ...options,
  });
}

前後比較に一癖あり

static methodの .compare(a, b) を用いると日時の前後関係を判定できますが、より直感的に扱えるユーティリティ関数を自作するのもよいでしょう。

// 第1引数が第2引数に対し、過去: -1, 一致: 0, 未来: 1。若干扱いづらい。
const result = Temporal.ZonedDateTime.compare(a, b);
const aIsBefore = result < 0;
  • date-fns Likeなユーティリティ関数を自作
export const isBefore = (a: Temporal.ZonedDateTime, b: Temporal.ZonedDateTime) => {
  return Temporal.ZonedDateTime.compare(a, b) < 0;
}
  • プロトタイプ拡張してもよい

[追記: 2025/09/10 12:35]

当初このセクションでは Temporal.ZonedDateTime のプロトタイプ拡張を例示しておりましたが、今後のWeb標準の更新などにより予期せぬ挙動を引き起こす原因となる可能性があるため削除させて頂きました。
ご懸念やご指摘のコメントをお寄せいただき、誠にありがとうございました。

バリデーションでの利用例: Zodスキーマ連携

フォームのバリデーション等にZodなどを利用されているチームも多いかと思います。 下記はZodで開始・終了時刻の前後関係が正しいかを検証する例です。

import { inputDateTimeToZdt, isBefore } from "./util";

const schema = z.object({
  StartsAt: z.string().transform((val) => inputDateTimeToZdt(val)),
  EndsAt: z.string().transform((val) => inputDateTimeToZdt(val)),
})
  .refine((values) => {
    if (values.StartsAt.equals(values.EndsAt)) return false;
    return isBefore(values.StartsAt, values.EndsAt);
  }, {
    message: "開始時間は終了時間より前に設定してください",
    path: ["StartsAt"],
  })

バックエンド連携: 基本はRFC3339で統一。タイムゾーンを厳密に扱いたい場合は RFC9557フォーマットを利用

タイムゾーンを表現できる RFC9557 フォーマット

時差だけを考慮するのであれば RFC3339 で必要十分です。 もしバックエンド / フロントエンド間でタイムゾーンを維持したい場合は、RFC3339にタイムゾーン情報を追加拡張した RFC9557 形式を用いるとよいでしょう。

GoバックエンドAPIとの連携例

  • Go実装
// タイムゾーン付きのRFC9557への変換
// Go 1.25現在で time.RFC9557は定義されていない
func timeToRFC9557Nano(now time.Time) string {
  return now.Format(time.RFC3339Nano) + "[" + now.Location().String() + "]"
}

func getEvent(w http.ResponseWriter, r *http.Request) {
 location, _ := time.LoadLocation("Asia/Tokyo")
 startsAt := time.Date(2025, 9, 6, 10, 0, 0, 0, location) //タイムゾーン付き日時を表現したい
 createdAt := time.Now().UTC() //マシン時刻をUTCで表現したい

event := struct {
  ID              string `json:"id"`
  Name            string `json:"name"`
  StartsAt        string `json:"starts_at"`
  CreatedAt       string `json:"created_at"`
  StartsAtWithTz  string `json:"starts_at_with_tz"`
  CreatedAtWithTz string `json:"created_at_with_tz"`
}{
  ID:             "1",
  Name:           "テックカンファレンス2025",
  StartsAt:       startsAt.Format(time.RFC3339),
  CreatedAt:      createdAt.Format(time.RFC3339Nano),
  StartsAtWithTz: timeToRFC9557Nano(startsAt),
  CreatedAtWithTz: timeToRFC9557Nano(createdAt),
}
 w.Header().Set("Content-Type", "application/json")
 json.NewEncoder(w).Encode(event)
}
  • APIレスポンス
{
  "id": "1",
  "name": "テックカンファレンス2025",
  "starts_at": "2025-09-06T10:00:00+09:00", // RFC3339
  "created_at": "2025-09-01T03:34:56.123456789Z", //最大ナノ秒精度のRFC3339
  "starts_at_with_tz": "2025-09-06T10:00:00+09:00[Asia/Tokyo]", // RFC9557
  "created_at_with_tz": "2025-09-01T03:34:56.123456789Z[UTC]", // 最大ナノ秒精度のRFC9557
}
  • フロントエンド

Temporalを使うとフロントエンドでもナノ秒精度 / タイムゾーンをそのまま扱うことが可能です。

const startsAt = rfc3339ToZdt(event.starts_at);
=> Temporal.ZonedDateTime 2025-09-06T10:00:00+09:00[Asia/Tokyo]

// ナノ秒精度で扱える
const createdAt = rfc3339ToZdt(event.created_at);
=> Temporal.ZonedDateTime 2025-09-01T12:34:56.123456789+09:00[Asia/Tokyo]

// 変換器を使わなくても文字列からタイムゾーン付き日時を生成できる
const startsAtWithTz = Temporal.ZonedDateTime.from(event.starts_at_with_tz);
=> Temporal.ZonedDateTime 2025-09-06T10:00:00+09:00[Asia/Tokyo]

// 変換器を使わないので、サーバーで設定したタイムゾーンが維持される
const createdAtWithTz = Temporal.ZonedDateTime.from(event.created_at_with_tz);
=> Temporal.ZonedDateTime 2025-09-01T03:34:56.123456789+00:00[UTC]

余談: Go time パッケージへのRFC 9557 (IXDTF)サポートを提案してみました

github.com

結論として、以下の観点から現時点で time でのIXDTF サポートは見送る結論となりました。

  • IXDTF自体はtagを key / value で自由に拡張できる仕様であるため、time.Time に直接埋め込むには不適当かもしれない
  • time.Time 自体はこれ以上太らせずに軽量に保っておくべきでは

折角ですので今後サードパーティのパッケージとして開発を検討してみようかと思います!

Temporalを触って次期標準に備えましょう

Temporal の導入により、日時処理の安全性・可読性・精度が大きく向上することが期待されています。標準化が待ち遠しいですね!

2025/09/08 現在のブラウザ対応状況

Date型からの移行を行う場合は、是非例示した変換器などをご参考に段階的移行を検討してみてください。 合言葉は "No more Month+1!!"

We are hiring

ミラティブでは一緒に開発してくれるエンジニアを募集しています! 少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、お気軽にご連絡ください。

speakerdeck.com

mirrativ.notion.site

www.mirrativ.co.jp