バックエンド

Firebase JS SDK 12.19.0入門——Temporal.InstantでFirestoreの時刻精度を守る

Firebase JavaScript SDK 12.19.0で追加されたFirestore TimestampとTemporal.Instantの相互変換を、Next.js App RouterとTypeScriptで実務に使う手順として解説します。

2026年9月17日
FirebaseFirestoreTemporalNext.jsTypeScript
Firebase JS SDK 12.19.0入門——Temporal.InstantでFirestoreの時刻精度を守る

はじめに

Firestoreで時刻を扱っていて、こんな違和感を持ったことはありませんか?

  • Date に変換した瞬間、ナノ秒精度が失われる
  • 予約、締切、監査ログの時刻をどの型で持つか毎回迷う
  • クライアント、Server Action、GitHub Actionsのバッチで時刻表現が揺れる
  • Timestamp をJSONにするときの境界があいまいになる

2026年9月9日、Firebase JavaScript SDK 12.19.0が公開され、Cloud Firestoreで Temporal.Instant と Timestamp の変換・シリアライズに対応しました。公式API referenceにも Timestamp.fromInstant() と timestamp.toInstant() が追加されています。

本記事では、架空の予約管理SaaS「YoyakuBase」を題材に、Next.js App Router + Firestore + TypeScriptで「時刻を正しく保存し、画面表示だけでローカル時刻へ変換する」実装を作ります。

参考にした一次情報は次の通りです。

何が変わったのか

Firebase公式リリースノートでは、12.19.0のCloud Firestore項目として Temporal.Instant とFirestore Timestamp の変換・シリアライズ対応が案内されています。Timestamp API referenceでは、次のメソッドを確認できます。

API役割実務での使いどころ
Timestamp.fromInstant(instant)Temporal.Instant から Timestamp を作る入力値や外部APIのUTC時刻をFirestoreへ保存する
timestamp.toInstant()Timestamp を Temporal.Instant に戻す計算、比較、レスポンス整形の基準時刻にする
Timestamp.fromJSON(json)toJSON() の結果から復元するAPI境界やジョブキューで安全に受け渡す
timestamp.toDate()JavaScript Date に変換するUI表示などミリ秒精度で十分な場面に限定する

ポイントは、Date をすぐに使わないことです。Firestoreの Timestamp はナノ秒を持てますが、Date はミリ秒精度です。予約や監査ログのように「順序」と「正確な保存値」が重要なデータでは、保存層では Timestamp、アプリの時刻計算では Temporal.Instant、表示直前だけ Date や Intl.DateTimeFormat を使うと整理しやすくなります。

事前準備

YoyakuBaseでは、顧客の予約、担当者の作業枠、監査ログをFirestoreに保存します。SDKは12.19.0以上を使う前提です。

yarn add firebase@12.19.0

この記事のコード例はWeb SDK側の firebase/firestore を使います。Firebase Admin SDKの firebase-admin/firestore とは型と実行環境が異なるため、サーバー専用処理とブラウザ共有処理を混ぜないようにします。

TypeScriptでは、Firestoreの保存型とアプリ内部の型を分けておくと後から壊れにくくなります。

// src/types/reservation.ts
import type { Timestamp } from "firebase/firestore";

export type ReservationStatus = "draft" | "confirmed" | "cancelled";

export type ReservationDocument = {
  customerName: string;
  staffId: string;
  startsAt: Timestamp;
  endsAt: Timestamp;
  status: ReservationStatus;
  updatedAt: Timestamp;
};

export type ReservationModel = {
  id: string;
  customerName: string;
  staffId: string;
  startsAt: Temporal.Instant;
  endsAt: Temporal.Instant;
  status: ReservationStatus;
};

ハンズオン1: TimestampとTemporal.Instantを変換する

まずは変換関数を薄く作ります。Date を経由しないのが目的なので、fromInstant() と toInstant() を明示的に使います。

// src/lib/time/firestoreTime.ts
import { Timestamp } from "firebase/firestore";

export function instantToTimestamp(instant: Temporal.Instant): Timestamp {
  return Timestamp.fromInstant(instant);
}

export function timestampToInstant(timestamp: Timestamp): Temporal.Instant {
  return timestamp.toInstant();
}

export function parseIsoInstant(value: string): Temporal.Instant {
  return Temporal.Instant.from(value);
}

入力値は必ずUTCの瞬間として扱います。たとえば 2026-09-17T10:00:00+09:00 は、Temporal.Instant にすると絶対時刻として扱われます。Firestoreへ保存するときも同じ瞬間を Timestamp として渡します。

const startsAt = parseIsoInstant("2026-09-17T10:00:00+09:00");
const timestamp = instantToTimestamp(startsAt);

console.log(timestamp.seconds);
console.log(timestamp.nanoseconds);

ハンズオン2: Server Actionで予約を保存する

Next.js App Routerでは、フォーム入力をServer Actionで受け取り、Firestoreへ保存する構成が扱いやすいです。ここでは入力チェックを最小限にしていますが、実プロダクトでは顧客ID、担当者ID、権限も検証します。

// src/app/reservations/actions.ts
"use server";

import { addDoc, collection, Timestamp } from "firebase/firestore";
import { db } from "@/lib/firebase/client";
import { instantToTimestamp, parseIsoInstant } from "@/lib/time/firestoreTime";
import type { ReservationDocument } from "@/types/reservation";

export async function createReservationAction(formData: FormData) {
  const startsAt = parseIsoInstant(String(formData.get("startsAt") ?? ""));
  const endsAt = parseIsoInstant(String(formData.get("endsAt") ?? ""));

  if (Temporal.Instant.compare(startsAt, endsAt) >= 0) {
    throw new Error("終了時刻は開始時刻より後にしてください。");
  }

  const reservation: ReservationDocument = {
    customerName: String(formData.get("customerName") ?? "").trim(),
    staffId: String(formData.get("staffId") ?? ""),
    startsAt: instantToTimestamp(startsAt),
    endsAt: instantToTimestamp(endsAt),
    status: "confirmed",
    updatedAt: Timestamp.now(),
  };

  await addDoc(collection(db, "reservations"), reservation);
}

updatedAt のように「保存した瞬間」が欲しい値は Timestamp.now() でも十分です。一方で、予約開始・終了のようにユーザー入力や外部連携で決まる時刻は、Temporal.Instant に変換してから Timestamp.fromInstant() へ渡すと境界が明確になります。

ハンズオン3: Firestoreから読み出して画面へ渡す

読み出し側では、Firestoreの Timestamp をアプリ内部の Temporal.Instant に変換します。画面表示のためにすぐ Date へ落とすと、後続の比較や並び替えで型が混ざります。

// src/lib/reservations/toModel.ts
import type { DocumentSnapshot } from "firebase/firestore";
import type { ReservationDocument, ReservationModel } from "@/types/reservation";
import { timestampToInstant } from "@/lib/time/firestoreTime";

export function reservationToModel(
  snapshot: DocumentSnapshot<ReservationDocument>,
): ReservationModel {
  const data = snapshot.data();

  if (!data) {
    throw new Error("Reservation document is empty.");
  }

  return {
    id: snapshot.id,
    customerName: data.customerName,
    staffId: data.staffId,
    startsAt: timestampToInstant(data.startsAt),
    endsAt: timestampToInstant(data.endsAt),
    status: data.status,
  };
}

表示直前では、プロジェクトで決めたタイムゾーンへ整形します。Temporal.Instant は絶対時刻なので、「東京時間で表示する」「ユーザー設定のタイムゾーンで表示する」という責務をUI側へ寄せられます。

// src/lib/time/formatInstant.ts
export function formatInstantForTokyo(instant: Temporal.Instant): string {
  return new Intl.DateTimeFormat("ja-JP", {
    dateStyle: "medium",
    timeStyle: "short",
    timeZone: "Asia/Tokyo",
  }).format(new Date(instant.epochMilliseconds));
}

GitHub Actionsで時刻の回帰を検査する

時刻まわりは一度壊れると気づきにくいので、CIで変換テストを回します。ここではNode.js 22で yarn build とテストを実行する想定です。

name: reservation-time-check

on:
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: yarn
      - run: yarn install --frozen-lockfile
      - run: yarn build

大事なのは、テストデータに境界値を入れることです。月末、年末、サマータイムがある地域のISO文字列、同一ミリ秒内の複数イベントを用意して、Timestamp と Temporal.Instant の相互変換で順序が変わらないことを確認します。

ケース確認すること
予約開始と終了startsAt < endsAt がUTC基準で判定される
監査ログ同一画面操作の順序が保存時刻で追える
外部API連携ISO 8601文字列を Temporal.Instant に寄せる
UI表示保存値ではなく表示層だけでタイムゾーン変換する

実務での注意点

Temporal.Instant は便利ですが、すべてを置き換える合図ではありません。Firestoreクエリでは引き続き保存値として Timestamp を使い、アプリ内部の計算や比較で Temporal.Instant を使う、と役割を分けるのが現実的です。

また、ブラウザや実行環境の対応状況は変わります。公式APIが追加されたからといって、古いブラウザを即座に無視できるわけではありません。利用者環境が広いサービスでは、対応ブラウザ、polyfill方針、ビルドターゲットを別途確認してください。

まとめ

Firebase JavaScript SDK 12.19.0の Temporal.Instant 対応は、小さなAPI追加に見えて、Firestoreの時刻設計をかなり整理してくれます。

  • 保存層はFirestore Timestamp に統一する
  • アプリ内部の比較・計算は Temporal.Instant に寄せる
  • Date は表示や既存ライブラリ連携の境界で使う
  • fromInstant() / toInstant() を変換関数に閉じ込める
  • CIでは月末、年末、タイムゾーン境界の回帰を確認する

予約、請求、監査ログのように時刻が業務ルールそのものになる機能では、早めに型の境界を決めておくと後の改修が楽になります。まずは新規モジュールから Temporal.Instant と Timestamp の変換を導入し、既存コードの toDate() 乱用を棚卸しするところから始めるのがおすすめです。