🎆

超型安全firestoreができた(更新)

2021/08/29に公開

firebase って typescript と相性わるいな~~~~~~~~~とまじで思ってたので超型安全 firestore 作ってみたらできました。パスまで型安全でやってくれます。これです。
https://github.com/Hagihara-A/fire-fuse

npm i firefuse
で使えます

firestore-v9 を想定しています。

特徴/出来ること

  • ただのユーティリティパッケージなので、一切の独自実装がない。ただし型安全。
  • パスの型付け
  • パスを解析してReferenceを型付けできる

なにができるか

// /user/{uid}/payment/{payId}/paymentLog/{logId}という関係のスキーマを定義済とする
const DB = firestore.getFirestore();

collection(DB, "user"); // ✅
collection(DB, "users"); // ❌: Type '"users"' is not assignable to type '"user"'
collection(DB, "user", "uid", "payment", "pid", "paymentLog"); // ✅
collection(DB, "user", "uid", "payment", "pid", "paymentsLog"); // ❌: Type '"paymentsLog"' is not assignable to type '"paymentLog"'

doc(DB, "user", "uid"); // ✅
doc(DB, "users", "uid"); // ❌: Type '"users"' is not assignable to type '"user"'
doc(DB, "user", "uid", "payment", "pid", "paymentsLog", "logid"); // ❌: Type '"paymentsLog"' is not assignable to type '"paymentLog"'

userusers とするような typo を防ぐことが出来ます。また、指定したパスを解析して、どのドキュメント/コレクションを参照しているのか型付けして返してくれます。つまりcollection(DB, "user")DocumentReference<User>の返り値になります。

使い方

まずはコレクションの階層関係を表したスキーマを定義します。

type Schema = {
  user: {
    doc: User;
    subcollection: {
      payment: {
        doc: Payment;
        subcollection: {
          paymentLog: {
            doc: PaymentLog;
          };
        };
      };
    };
  };
};
type User = {
  name: { first: string; last: number; middle?: string };
  age: number;
  sex: "male" | "female" | "other";
  birthDay: firestore.Timestamp;
  skills: string[];
  isStudent: boolean;
};

type Payment = {
  company: string;
  cardNumber: number;
  expire: firestore.Timestamp;
};

type PaymentLog = {
  settledAt: firestore.Timestamp;
  amount: number;
};

長いですが複雑ではないです。
このスキーマがどういう仕組みになっているかというと

  • user/{userId} -> User
  • user/{userId}/payment/{paymentId} -> Payment
  • user/{userId}/payment/{paymentId}/paymentLog/{logId} -> PaymentLog

という、ドキュメントとそこに保存されているオブジェクトの型の関係を表します。このスキーマを作ることで、パスの型付けが可能になります。このスキーマを与えて、firestore を操作する関数を得ます。

import * as fuse from "firefuse";
const collection = fuse.collection<Schema>();
const doc = fuse.doc<Schema>();
const where = fuse.where<User>();
const orderBy = fuse.orderBy<User>();

これだけです。他の設定は一切要りません。

また、ここで生成したcollection docはオリジナル firestore v9 と同じインターフェイスをもちます。

実際に使ってみます

// const DB = getFirestore();
const paymentColRef = collection(DB, "user", "a", "payment"); // ✅
const paymentColRef = collection(DB, "not", "exists", "path"); // ❌:     型 '["not", "exists", "path"]' を型 '["user", string, "payment"]' に割り当てることはできません。
const paymentDocRef = doc(DB, "user", "a", "payment", "b"); // ✅
const paymentDocRef = doc(DB, "user", "a", "not", "exists"); // ❌:   型 '["user", "a", "not", "exists"]' を型 '["user", string, "payment", string]' に割り当てることはできません。

コレクション/ドキュメントの参照を型安全にできました。これが一番気に入ってる機能です。

クエリ

クエリも型安全です。

const userCol = collection(DB, "user");
const { query } = fuse;
const where = fuse.where<User>();
const orderBy = fuse.orderBy<User>();

クエリは色々できることがあります。

存在しないキーと、型が違う値をクエリにできない

where("age", "==", 22); // ✅
where("age", "==", "22"); // ❌: Argument of type 'string' is not assignable to parameter of type 'number'.

よくあるやつです

クエリしたフィールドは必ず存在する

firestore の仕様として、クエリしたフィールドがないドキュメントはマッチしないというのがあります。つまり、ヒットしたドキュメントは必ずそのフィールドを持ちます。これも型付けできます。

const q1 = query(userCol, where("age", ">", 22)); // ✅: queried docs are typed to have `.age` property
firestore.getDocs(q1).then((qs) => qs.docs.map((doc) => doc.data().age));

この age が optional だったとしても、required でかえってきます。

不正なクエリを検知する

firestore には!=not-inを組み合わせられないとか、array-containsarray-contains-anyを組み合わせられないとか、さらにin not-in array-contains-anyのうちひとつまでしか使えないというルールが山ほどあります。これに当てはまるクエリをすると、neverで返します。

const q2 = query(userCol, where("age", ">", 22), where("sex", "<", "male")); // ❌: filter on multiple field (firestore's limitation).
firestore.getDocs(q2).then((qs) => qs.docs.map((doc) => doc.data())); // now, doc.data() is never

const q3 = query(
  userCol,
  where("age", ">", 22),
  where("sex", "not-in", ["male"])
); // ❌: "<", "<=", ">=", ">", "!=" and not-in must filter the same field (firestore's limitation).
firestore.getDocs(q3).then((qs) => qs.docs.map((doc) => doc.data().age)); // doc.data() is never

query(
  userCol,
  where("sex", "in", ["female", "male"]),
  where("age", "not-in", [22, 23]),
  where("skills", "array-contains-any", ["c", "java"])
); // ❌:  in, not-in or array-contains-any must not be used at the same time and appear only once (firestore's limitation).

query(userCol, where("age", "<", 22), orderBy("age"), orderBy("birthDay")); // ✅
query(userCol, where("age", ">", 23), orderBy("birthDay")); //❌: if you include a filter with a range comparison (<, <=, >, >=), your first ordering must be on the same field: (firestore's limitation)

// use other constraints
const { limit, limitToLast, startAt, startAfter, endAt, endBefore } = fuse;

こんな感じですね。ちなみに firestore エミュレータには通るけど、本番環境だと通らないクエリが存在するらしく、そういうのも事前に把握できます。

==in, not-inなどで制限した値に型がつく

上述のUserだとsexプロパティに"male" "female" "other"というフラグを設定して管理してたとします。これにwhere("sex", "!=", "male")クエリかけても、素の firestore はそれがわからないので、型付けしてくれません。firefuseならこれもできます。

const q4 = query(userCol, where("sex", "!=", "male" as const)); // ✅: note `as const`
firestore.getDocs(q4).then((qs) => qs.docs.map((doc) => doc.data().sex)); // now, sex is `"female" | "other"` because you removed it !!

const q5 = query(userCol, where("age", "==", 30 as const)); // ✅: note `as const`
firestore.getDocs(q5).then((qs) => qs.docs.map((doc) => doc.data().age === 30)); // now age === 30, becase you queried!!

みてのように、where(sex, !=, male)すると、sex: "female" | "other"と推論します。where(age, ==, 30)にすると、age: 30に推論します。

なにが他のパッケージと違うのか

これまで firestore を操作するパッケージは数多く出てきました。しかしその多くは、かゆいところに手が届かないような、微妙な使い勝手の悪さがありました。このような時にいちいち元の firestore を引っ張り出してきて、無理やり実装するということをやった事のある人は多いかもしれません。

一方このパッケージではそのようなことは起こりません。先に述べたように、ユーティリティ関数のパッケージだからです。内部的には、collectiondocをラップしただけの実装になっています。型チェックをするだけです。

firebase v9 への移行の時に一緒に使ってみてください。

Discussion