Skip to content

Instantly share code, notes, and snippets.

@x7ddf74479jn5
Last active August 10, 2024 16:06
Show Gist options
  • Save x7ddf74479jn5/c03b5f04809c35d8e9f8d54b9be514dc to your computer and use it in GitHub Desktop.
Save x7ddf74479jn5/c03b5f04809c35d8e9f8d54b9be514dc to your computer and use it in GitHub Desktop.
FirestoreをTypeScriptで書くためのメモ

型安全Firestoreへの道

無理くり通してるところがあるので間違ってるかもしれない。

目的

TypeScriptで書くと楽なので、Firestoreも可能な限り型安全に使いたい。

方針

  • zodでスキーマを定義する。
  • コードベースや時間効率性への配慮は断念し、型を付けることを優先する。
  • FieldValue型といった特殊な型は強制型キャストで妥協するが、最低限のモジュール境界を設ける。
  • 利用側では透過的に取り扱いたいのでConverterに畳み込む。
  • lib, model, repository, usecaseにレイヤーを分け依存関係を整理する。

リンク

next-firebase-starter

関連: Firestore倹約術

Model

// src/models/book.ts
import * as z from "zod";

export const bookSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
});

export type Book = z.infer<typeof bookSchema>;

Converter

TypeScriptへの変換はConverter内部で行う。 read/writeともにassert関数で不正な値が混入しないか監視している。

FirestoreDataConverterはtofirestore, fromFirestoreのメソッドを持つオブジェクト。 これを○○Referenceに生えてるwithConverter関数に与えて、ミドルウェア処理を加えることが可能。

  • toFirestore: addDoc関数やupdateDoc関数などのwrite処理に割り混む
  • fromFireStore: getDoc関数のようなread処理に割り混む

fromFirestoreの途中、Timestamp型が扱いづらいのでDate型に変換している。 write側ではDate型をそのまま投げてもFirestore内部で自動的にTimestamp型に変換される。

snapshot.data({ serverTimestamps: "estimate" })はserverTimestamp関数がnullになる問題を回避するため。(write時にTimestampは手元になく、サーバーの値を反映するまでラグがある。)

// src/lib/firebase.ts
import type { FirestoreDataConverter, QueryDocumentSnapshot, WithFieldValue } from "firebase/firestore";

const getConverter = <T extends object>(assert: (data: unknown) => asserts data is T): FirestoreDataConverter<T> => ({
  toFirestore: (data: WithFieldValue<T>) => {
    assert(data);

    return data;
  },
  fromFirestore: (snapshot: QueryDocumentSnapshot) => {
    const data = snapshot.data({ serverTimestamps: "estimate" });

    const result = Object.fromEntries(
      Object.entries(data).map(([key, value]) => {
        if (typeof value.toString == "function" && value.toString().startsWith("Timestamp")) {
          return [key, value.toDate()];
        }
        return [key, value];
      })
    );

    assert(result);

    return result;
  },
});

Repository

やっていることはconverter生成関数に型引数となるmodelとassert関数を与えて専用converterを作り、CollectionReference, DocumentReferenceの末尾にくっつけただけ。 これで、型のついたCollectionReference, DocumentReferenceを得られる。 コードではジェネリクスで型引数をしているけど、zodのスキーマをconverter生成関数に注入してもいいかもしれない。(firebase側のモジュールにzodが露出するけど)

// src/repositories/book.ts
import type { PartialWithFieldValue } from "firebase/firestore";
import { addDoc, deleteDoc } from "firebase/firestore";
import { collection, doc, getDoc, getDocs, updateDoc } from "firebase/firestore";

import { db, getConverter } from "@/lib/firebase";
import type { Book } from "@/models/book";
import { bookSchema } from "@/models/book";

const bookConverter = getConverter<Book>(bookSchema.parse);

const getBookDocRef = (id: string) => {
  return doc(db, "books", id).withConverter(bookConverter);
};

const getBookColRef = () => {
  return collection(db, "books").withConverter(bookConverter);
};

export const addBook = async (book: Book) => {
  await addDoc(getBookColRef(), book);
};

export const getBook = async (id: string) => {
  const doc = await getDoc<Book>(getBookDocRef(id));
  return doc.data();
};

export const getBooks = async () => {
  const snapshot = await getDocs<Book>(getBookColRef());
  return snapshot.docs.map((doc) => doc.data());
};

export const updateBook = async (id: string, book: PartialWithFieldValue<Book>) => {
  await updateDoc<Book>(getBookDocRef(id), book);
};

export const deleteBook = async (id: string) => {
  await deleteDoc(getBookDocRef(id));
};

Hack

Firestoreの便利関数群はFieldValue型を返すことがあり、これが厄介すぎる。

Sentinel values that can be used when writing document fields with set() or update().

arayUnionとかはサーバー側で特別な処理をするためのマジック関数なので、クライアント側で関数の返り値はどうしてもFieldValue型になってしまう。 FieldValue型から適切なTypeScriptの型は取り出せないので強制型変換で無理矢理型を通している。なにか適切なワークアラウンドがほしいところ。

// src/lib/firebase.ts
const typedArrayUnion = <T>(...args: any[]) => {
  return arrayUnion(...args) as unknown as Array<T>;
};

const typedArrayRemove = <T>(...args: any[]) => {
  return arrayRemove(...args) as unknown as Array<T>;
};

const typedServerTimestamp = () => {
  return serverTimestamp() as unknown as Date;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment