0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アプリ全体で使用するデータモデルに Firestore の Timestamp 型を入れるな 〜「型の境界」を正しく引く〜

Last updated at Posted at 2025-11-25

Firestore を TypeScript で扱う場合、zod を使って取得データを型安全にパースするのが一般的です。しかし、Firestore の Timestamp 型を zod スキーマの内部にそのまま入れてしまうと、アプリ全体で Firestore SDK への依存が広がり、保守性や移植性が大きく低下します。

本記事では、

  • Firestore の Timestamp 型をデータモデルに含めることの問題点
  • Date 型を使用しつつ ServerTimestamp を活用する実装方法
  • 型の境界を正しく引くことの重要性

についてまとめます。

問題点

TypeScript で Firestore を使うとき、取得したデータを型安全に扱いたくなります。そのため、Firestore のドキュメントを zod でパースして entity に変換する方法をよく使います。

import { z } from "zod";
import { Timestamp } from "firebase/firestore";
const userIdSchema = z.string().brand("UserId");
type UserId = z.infer<typeof userIdSchema>;

const userNameSchema = z.string().brand("UserName");
type UserName = z.infer<typeof userNameSchema>;

const createdAtSchema = z.instanceof(Timestamp).brand("CreatedAt");
type CreatedAt = z.infer<typeof createdAtSchema>;

const updatedAtSchema = z.instanceof(Timestamp).brand("UpdatedAt");
type UpdatedAt = z.infer<typeof updatedAtSchema>;

const userSchema = z.object({
  id: userIdSchema,
  name: userNameSchema,
  createdAt: createdAtSchema,
  updatedAt: updatedAtSchema,
});
type User = z.infer<typeof userSchema>;

これで型安全に Firestore のデータを扱えるのは良いところです。
例えば以下のように getUser を定義することで、型のついたデータを取得することができます。

import { doc, getDoc, setDoc, getFirestore } from "firebase/firestore";
const db = getFirestore();

async function getUser(id: UserId): Promise<User> {
  const snap = await getDoc(doc(db, "user", id));
  if (!snap.exists()) throw new Error("Document not found");

  const parsed = userSchema.safeParse(snap.data());
  if (!parsed.success) throw new Error("Invalid data");

  return parsed.data; // User 型
}

しかし、この getUser 関数で取得できる user entity の createdAtupdatedAt は、定義の通り Timestamp 型となります。これはいくつかの点でわずらわしさを引き起こします。

① アプリの至るところが Firestore に依存してしまう

Timestamp 型を扱うために、どこでも Firestore SDK を import する必要があります。
React の UI コンポーネントやユーティリティ関数が Firestore に依存するのは本来おかしく、レイヤードアーキテクチャ的にもアンチパターンです。

② Date 操作ライブラリと相性が悪い

react-datepickerdayjs など、ほとんどのライブラリが Date を前提にしているので、毎回 dayjs(createdAt.toDate()) のような変換が必要となります。全てのコードで .toDate() が散らばるのは冗長です。

③ DB を Firestore 以外の何かに変更するのが大変

Timestamp 型は Firestore 固有の型です。
アプリの Entity が Firestore の概念を含んでしまうと、変更範囲の影響は広範囲に及び、DB migration が大変になります。

Schema から Firestore Timestamp を追い出す

上記の点を考慮すると、userSchema の型は、特定のライブラリに依存せず、一般的な Date 型として定義しておいた方がよさそうです。

import { z } from "zod";

const userIdSchema = z.string().brand("UserId");
const userNameSchema = z.string().brand("UserName");
const createdAtSchema = z.date().brand("CreatedAt");
const updatedAtSchema = z.date().brand("UpdatedAt");

const userSchema = z.object({
  id: userIdSchema,
  name: userNameSchema,
  createdAt: createdAtSchema,
  updatedAt: updatedAtSchema,
});
type User = z.infer<typeof userSchema>;

ここで注意が必要なのは、データを追加する時です。

import { doc, setDoc, getFirestore } from "firebase/firestore";
const db = getFirestore();

async function addUser(user: User) {
  const docRef = doc(db, "user", user.id);
  await setDoc(docRef, user);
}

上記のような関数を定義したとします。すると、addUser の引数の updatedAtcreatedAt の型は Date 型です。つまり端末で取得した日時が Firestore に保存されることとなります。つまり、createdAtupdatedAtServerTimestamp が使用できなくなるということです。

ServerTimestamp とは:Firestore の serverTimestamp() は、データ保存時にサーバー側の時刻を自動的に設定するための関数です。クライアント側の時刻ではなく、サーバー側の時刻を使用することで、タイムゾーンの違いや端末の時刻設定の誤りに影響されない、正確な時刻を記録できます。

DB に保存されるデータの日付が端末のローカル設定に依存しないようにするためにも、ServerTimestamp は利用できるようにするべきです。

そのため、createdAtupdatedAtaddUser の引数には含めず、関数内で作成するように変更することをお勧めします。createdAtupdatedAt はユーザーから明示的に指定することはまずないので、むしろ addUser 内に隠蔽しておいた方が安全でもあります。

import { doc, setDoc, serverTimestamp, getFirestore } from "firebase/firestore";
const db = getFirestore();

async function addUser(user: Omit<User, "createdAt" | "updatedAt">) {
  const docRef = doc(db, "user", user.id);
  const createdAt = serverTimestamp();
  const updatedAt = serverTimestamp();
  await setDoc(docRef, { ...user, createdAt, updatedAt });
}

また、Firestore から取得したデータを Date 型に変換する必要があります。以下のように getUser 関数を修正します。

import { doc, getDoc, getFirestore } from "firebase/firestore";
const db = getFirestore();

async function getUser(id: UserId): Promise<User> {
  const snap = await getDoc(doc(db, "user", id));
  if (!snap.exists()) throw new Error("Document not found");

  const data = snap.data();
  // Timestamp を Date に変換
  const convertedData = {
    ...data,
    createdAt: data.createdAt?.toDate(),
    updatedAt: data.updatedAt?.toDate(),
  };

  const parsed = userSchema.safeParse(convertedData);
  if (!parsed.success) throw new Error("Invalid data");

  return parsed.data; // User 型(Date 型の createdAt, updatedAt を含む)
}

まとめ

これによって、Firestore の Timestamp 型は Firestore の操作をする関数内に隠蔽し、アプリ全体では Date 型を利用しつつ、ServerTimestamp を使うという、いいとこ取りの実装が実現できました。

型の境界を正しく引くことの重要性

この実装パターンの本質は、「型の境界」を正しく引くことです。Firestore 固有の型(Timestamp)は、Firestore とのやり取りを行う層(リポジトリ層など)に閉じ込め、アプリケーション全体で使用する Entity 型には標準的な Date 型を使用します。これにより:

  • 依存関係の明確化:Firestore への依存が特定の層に限定される
  • 移植性の向上:データベースを変更する際の影響範囲が限定的になる

余談:Monorepo 構成での共有

userSchema から firebase/firestoreTimestamp を追い出すメリットは他にもあります。Monorepo 構成で userSchema を client-side でも server-side でも共有できるということです。

上記例では userSchemafirebase/firestoreTimestamp を使用していましたが、これは client-side の SDK です。server-side で Firestore の Timestamp を使用するには別のライブラリである firebase-adminTimestamp を使用する必要があります。これら 2 つの Timestamp は全くの別物ですし、client-side で firebase-adminTimestamp を import すると build 時にエラーが発生します。

userSchema を client-side と server-side で共通して使用できるようにする目的でも、Firestore Timestamp の依存を取り除くメリットはあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?