💥

型安全firestore

8 min read

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回くらいに制限されるし、ライブラリはバカ重い(目下改善中らしい)ので結構使いにくい。使い方を選びましょう。

Discussion

ログインするとコメントできます