💥

型安全firestore

2021/03/18に公開
1

firestoreをTypescriptで使うときに、型つかないので少しはまともそうな方法を紹介する。長いので最後のまとめだけ読めばOK。

firestoreの問題点

  1. get()すると全て{[key: string]: any}で返ってくる
  2. Dateが勝手に変換される
  3. firebasefirebase-adminで型が微妙に違うのでエラーでる

一つずつ対応策を書く。

1: getの返り値を型付けする

これは自分でキャストする方法と、withConverterを使う方法がある。

キャストを使う

interface User {
  uid: string;
  address: string;
  age: number;
}
const getUserDoc = (uid: string) =>
    DB.doc(`user/${uid}`) as firebase.firestore.DocumentReference<User>;

一番簡単

withConverterを使う

つぎにwithConverter

function assertUser(data: any): asserts data is User {
  const d = data as Partial<User>; // 補完のためキャスト
  if (
    !(
      typeof d?.uid === "string" &&
      typeof d?.address === "string" &&
      typeof d?.age === "number"
    )
  ) {
    throw new Error("data is not User type");
  }
}

const userConverter: firebase.firestore.FirestoreDataConverter<User> = {
  fromFirestore(ss, op) {
    const data = ss.data(op);
    assertUser(data);
    return data;
  },
  toFirestore: (model: User) => model,
};

const getUserDoc = (uid: string) =>
  DB.doc(`user/${uid}`).withConverter(userConverter);
const userSS = await getUserDoc("uid2").get();

assertsというのはこれ

メリット: 型安全度(?)がキャストよりは高い。

デメリット: asserts関数のユニットテスト書くのがだるい

まとめ

どうせプロパティ存在しなかったらどこかでエラー出るからキャストでいい気もする

2: Dateがだるい

まずは挙動から。

適当なclassをsetすると、以下のようにエラーが出る。

const cls = new (class {})(); // 空のclass
await DB.doc("a/b").set({cls}); //FirebaseError: Function DocumentReference.set() called with invalid data. Data must be an object, but it was: a custom object (found in document a/b)

じゃあclass全般ダメなのかというとそうでもなくて、Dateは許されてるらしい。

const doc = DB.doc(`a/b`);
await doc.set({ date: new Date() });
const date = (await doc.get()).data()?.date;
console.log(date instanceof firebase.firestore.Timestamp); // true

注意点として、Dateをsetすると、Dateがfirebase.firestore.Timestampに勝手に変換される(上のコード参照)。

保存するときにDate->Timestampと勝手に変換されるということは、getするときには逆の変換処理が必要になる。これの対応案。

ちなみになぜTimestampで保存されるのかというと、JSのDateはミリ秒までしか扱えないが、firestoreはナノ秒まで記録するから。

DateじゃなくてNumberで保存する

firestoreをJSからしか利用しないならこれが楽。

const doc = DB.doc("b/c");
const date: number = Date.now();
await doc.set({ date });
const givenDate: number = (await doc.get()).data()?.date;
const date2: Date = new Date(givenDate);

withConverterを使って、Date<->Timestampを変換する

この方法はかなりメンテナンスコストが高いので、できればやりたくない。
以下のfromFirestoreでやってるのは

  1. Timestamp->Dateへ再帰的に変換
  2. assertionFunctionで型付け

だけなんだけど、実際に使ってると、assertionFuinctionのテストと、timestampToDateのテストを両方書くことになるのでかなりめんどくさい。しかも、interfaceを変えるとassertionFuinctionも追従させなきゃいけない。だるい。読むのもだるい。ほんとにだるい。

import firebase from "firebase";
interface MyDocumentData {
  date: Date;
  events: Date[];
}
const DB = firebase.firestore();

const converter: firebase.firestore.FirestoreDataConverter<MyDocumentData> = {
  fromFirestore(ss, op) {
    const data = ss.data(op);
    const t2d = timestampToDate(data);
    assertsMyDocData(t2d);
    return t2d;
  },
  toFirestore(model: MyDocumentData) {
    return model;
  },
};
function assertsMyDocData(data: any): asserts data is MyDocumentData {
  const d = data as MyDocumentData;
  if (!(d.date instanceof Date && d.events.every((v) => v instanceof Date))) {
    throw new Error("");
  }
}
function timestampToDate(data: firebase.firestore.DocumentData) {
  const t2dEntries: [string, any][] = Object.entries(data).map(([key, val]) => {
    if (val instanceof firebase.firestore.Timestamp) {
      return [key, val.toDate()];
    } else if (val instanceof Array) {
      const newVal = val.map((v) =>
        v instanceof firebase.firestore.Timestamp
          ? v.toDate()
          : v instanceof Object
          ? timestampToDate(v)
          : v
      );
      return [key, newVal];
    } else if (val instanceof Object) {
      return [key, timestampToDate(val)];
    } else {
      return [key, val];
    }
  });
  return Object.fromEntries(t2dEntries);
}

const doc = DB.doc("f/g").withConverter(converter);
await doc.set({ date: new Date(), events: [new Date()] });
const dataRecieved = (await doc.get()).data();
console.log(dataRecieved?.date instanceof Date); //ture
console.log(dataRecieved?.events[0] instanceof Date); //true

まとめ

だるいので出来るだけNumberで保存するようにしましょう。

3: firebaseとfirebase-adminで微妙に型が違う

このコードはエラーでます。探してみてください(初期化忘れを除いて)。

import firebase from "firebase";
import * as admin from "firebase-admin";

interface User {
  uid: string;
}
let adminCVT: admin.firestore.FirestoreDataConverter<User>;

const converter: firebase.firestore.FirestoreDataConverter<User> = adminCVT;

答えはこれです。

adminAndClient.ts:16:7 - error TS2322: Type 'FirebaseFirestore.FirestoreDataConverter<User>' is not assignable to type 'firebase.default.firestore.FirestoreDataConverter<User>'.
  Types of property 'fromFirestore' are incompatible.
    Type '(snapshot: QueryDocumentSnapshot<DocumentData>) => User' is not assignable to type '(snapshot: QueryDocumentSnapshot<DocumentData>, options: SnapshotOptions) => User'.
      Types of parameters 'snapshot' and 'snapshot' are incompatible.
        Type 'QueryDocumentSnapshot<DocumentData>' is missing the following properties from type 'QueryDocumentSnapshot<DocumentData>': createTime, updateTime, readTime

16 const converter: firebase.firestore.FirestoreDataConverter<User> = adminCVT;
         ~~~~~~~~~


Found 1 error.

要するにfirebaseとadminで互換ないからエラー出てる。だからfirebase-adminで作ったconverterとfirebaseで作ったconverterを使いまわすことができない。だるい。

解決策

どうにかして使いまわしたいので、自分でconverterの型定義を作る。

interface SnapshotOptions {
  readonly serverTimestamps?: "estimate" | "previous" | "none";
}
interface DocumentData {
  [key: string]: any;
}
interface QueryDocumentSnapshot {
  data(option?: SnapshotOptions): DocumentData;
}
type Converter<T> = {
  fromFirestore(
    snapshot: QueryDocumentSnapshot,
    op?: SnapshotOptions
  ): T;
  toFirestore(model: Partial<T>): DocumentData;
};

この定義を使うと、

interface User {
  uid: string;
}
const converter: Converter<User> = {
  fromFirestore(ss, op) {
    return ss.data(op) as User;
  },
  toFirestore(model) {
    return model;
  },
};

const adminCVT: admin.firestore.FirestoreDataConverter<User> = converter;
const clientCVT: firebase.firestore.FirestoreDataConverter<User> = converter;

みたいに、firebasefirebase-adminでconverterを使いまわせる(動作確認はしてないけど多分動く)。

まとめ

firestoreはこうやって使いましょう。

import firebase from "firebase/app";
// import * as firebase from "firebase-admin"; //どちらか
const DB = firebase.firestore()
// 一番簡単な方法
const getUserDoc = (uid: string) =>
    DB.doc(`user/${uid}`) as firebase.firestore.DocumentReference<User>

// 頑張りたい人
interface SnapshotOptions {
  readonly serverTimestamps?: "estimate" | "previous" | "none";
}
interface DocumentData {
  [key: string]: any;
}
interface QueryDocumentSnapshot {
  data(option?: SnapshotOptions): DocumentData;
}
type Converter<T> = {
  fromFirestore(snapshot: QueryDocumentSnapshot, op?: SnapshotOptions): T;
  toFirestore(model: Partial<T>): DocumentData;
};

interface User {
  uid: string;
}
const userConverter: Converter<User> = {
  fromFirestore(ss, op) {
    // ここでassertionしたり、Timestamp->Dateを挟んだりする
    return ss.data(op) as User;
  },
  toFirestore(model) {
    return model;
  },
};

export const getUserDoc = (uid: string) =>
    DB.doc(`uid/${uid}`).withConverter(userConverter);
;

見てのとおりfirestoreは型付けるの(真面目にやろうとすると)大変だし、書き込み速度が毎秒1回くらいに制限されるし、ライブラリはバカ重い(目下改善中らしい)ので結構使いにくい。使い方を選びましょう。

追記

firestoreの型定義が気に入らないので、動作は全く変えずに型推論だけ頑張ってくれるライブラリを作りました。

https://github.com/Hagihara-A/fire-fuse

Discussion

KeisukeNagakawaKeisukeNagakawa

大変参考になりました。ありがとうございます。ちなみにこれって、Queryを利用した場合ってどうなるのでしょうか...?例えば、

 const docsRef = db.collection("issues").where("city", "==","tokyo")

というコードが合った場合、左記の

docsRef<FirebaseFirestore.CollectionReference<FirebaseFirestore.DocumentData>>

をキャストしたい場合です。もしご存知でしたらご教示いただけるとうれしいです。