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 の createdAt や updatedAt は、定義の通り Timestamp 型となります。これはいくつかの点でわずらわしさを引き起こします。
① アプリの至るところが Firestore に依存してしまう
Timestamp 型を扱うために、どこでも Firestore SDK を import する必要があります。
React の UI コンポーネントやユーティリティ関数が Firestore に依存するのは本来おかしく、レイヤードアーキテクチャ的にもアンチパターンです。
② Date 操作ライブラリと相性が悪い
react-datepicker や dayjs など、ほとんどのライブラリが 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 の引数の updatedAt や createdAt の型は Date 型です。つまり端末で取得した日時が Firestore に保存されることとなります。つまり、createdAt や updatedAt に ServerTimestamp が使用できなくなるということです。
ServerTimestamp とは:Firestore の serverTimestamp() は、データ保存時にサーバー側の時刻を自動的に設定するための関数です。クライアント側の時刻ではなく、サーバー側の時刻を使用することで、タイムゾーンの違いや端末の時刻設定の誤りに影響されない、正確な時刻を記録できます。
DB に保存されるデータの日付が端末のローカル設定に依存しないようにするためにも、ServerTimestamp は利用できるようにするべきです。
そのため、createdAt や updatedAt は addUser の引数には含めず、関数内で作成するように変更することをお勧めします。createdAt や updatedAt はユーザーから明示的に指定することはまずないので、むしろ 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/firestore の Timestamp を追い出すメリットは他にもあります。Monorepo 構成で userSchema を client-side でも server-side でも共有できるということです。
上記例では userSchema に firebase/firestore の Timestamp を使用していましたが、これは client-side の SDK です。server-side で Firestore の Timestamp を使用するには別のライブラリである firebase-admin の Timestamp を使用する必要があります。これら 2 つの Timestamp は全くの別物ですし、client-side で firebase-admin の Timestamp を import すると build 時にエラーが発生します。
userSchema を client-side と server-side で共通して使用できるようにする目的でも、Firestore Timestamp の依存を取り除くメリットはあります。