📈

Firestore と Cloud Functions の開発効率を型で上げる方法

2022/12/05に公開

これは 天久保 Advent Calendar 2022 の 5 日目の記事です。

TypeScript などでフロントエンドを書いているが、Firestore から取得する値はanyasでごまかしていて気持ち悪い…といった経験はありませんか?
あるいは Firestore から取得できるデータが設計と違っていてバグの発見が遅れた…という経験はどうでしょう?

この記事では Firebase の Firestore, Cloud Functions に実行時型検査を導入して開発効率を上げる方法について書きます。似たような記事は多いですが、この記事では型検査を導入することでなぜ開発効率があがるのかというところから、フロントエンドで使われる Firebase SDK と Cloud Functions for Firebase で使われる Firebase Admin SDK の差異を吸収する方法まで丁寧に書きたいと思います。

末尾には、ハッカソンで作ったものなので粗雑で恐縮ですが、サンプルプロジェクトのリンクも掲載しています。そのときに実際にこの手法を試してみて、たしかに開発効率が上がったことを実感したので今回の記事を書きました。

実行時型検査を導入すると開発効率が上がる理由

Firestore のデータ構造のミスにすぐに気がつくことができるため

Firestore はドキュメントデータベースなので型のミスが発生しやすいです。RDB のようにテーブル毎にスキーマが決定されず、以下のように無数のコレクション(ディレクトリに相当するもの)の中のあるドキュメント(ファイルに相当するもの)のプロパティが一つだけ違うということがあった場合、バグの発見が遅れることが多々あります。

Firestoreは型のミスが多い

特に Firestore はハッカソンや MVP (Minimum Viable Product) の作成に使われることが多く、そうした開発ではデータ構造が途中からコロコロ変わることが多いです。なので上記のようなコレクション以下のデータスキーマが揃っていないなどのことは想像以上によく起こります。

ここで実行時型検査が導入されれば、以下のようにデータ型が違っていてもややこしいバグを引き起こす前にデータを取得した時点でエラーが出るため、早急に対処することができます。

エラーがコンソールに出てくる様子

フロントエンド、Firestore、Cloud Functions for Firebase でコミュケーションのコストが減る

これは「実行時」に型検査をするメリットではなく、実行前に型を導入するメリットです。

前述の通り Firestore はハッカソンや MVP (Minimum Viable Product) の作成に使われることが多いので、十分な設計やドキュメントをもとに実装を行えることは少ないです。そのためフロントエンド、Firestore、Cloud Functions for Firebase でやり取りするデータ型はどうしても曖昧なコミュケーションなどで決定されます。その結果、どちらかが仕様を勘違いしたり明言せず変更したりすることで、不要なバグが発生し開発が停滞します。

フロントエンド、Firestore、Cloud Functions for Firebase 間で一つの型を共有すれば、いづれかが型を変更したり間違ったりしても型エラーになるため、バグを抑制できますし、型をみれば何を与えて何が返ってくればいいかが明確なのでコミュケーションコストが減り、開発が加速します。

方向を絞る

Firestore、Cloud Functions for Firebase に実行時型検査を導入する方法

導入は主に 4 ステップに分かれています。

  1. io-ts などを用いて型を定義
  2. .withConverter で Firestore に実行時型検査を挿入
  3. Cloud Functions for Firebase に型定義をつける
  4. フロントエンドの Firebase SDK と Cloud Functions for Firebase の Firebase Admin SDK の型の差異を吸収

1. io-ts などを用いて型を定義

io-ts は、実行時にデータのデコード・エンコードをしてくれる TypeScript のライブラリです。

今回は io-ts をデコード・エンコードをする目的ではなく、動的に型ガードを生成する目的で使用します。io-ts 以外だと Zod とかでも問題ないと思います。

この記事では io-ts を使用します。

io-ts で以下のように型(のようなもの)を定義することができます。
これは io-ts によって提供される、実行時に型情報を扱うための型情報を表現した値です。
下記のように io-ts で定義された型のようなものを、今後は「t.Typeの値」と呼ぶことにします。
t.Typeの値」は「TypeScript の型」とは別物なので注意してください。

$ npm i io-ts
import * as t from "io-ts";

// これは t.type の値
const User = t.type({
  name: t.string,
  age: t.number,
  sex: t.union([t.literal("male"), t.literal("female")]),
  items: t.array(
    t.type({
      name: t.string,
      count: t.number,
    })
  ),
});

// 上記は以下と同じような意味
// これは TypeScript の型
type User = {
  name: string;
  age: number;
  sex: "male" | "female";
  items: {
    name: string;
    count: number;
  }[];
};

これを用いることで、以下のように any 型を.is()で検査することができます。

const user: any = {
  name: "name 1",
  age: 1,
  sex: "male",
  items: [
    {
      name: "item name 1",
      count: 10,
    },
  ],
};

const invalidUser = {
  name: "name 2",
  age: "2",
};

User.is(user); // true
User.is(invalidUser); // false

また .is()は Type Guard なので、以下のように型推論を効かせることができます。

if (User.is(user)) {
  // この中では user の型推論が効く
}

またt.Typpeの値から TypeScript の本物の型を導出することもできます。
こちらは Cloud Functions for Firebase で引数と返り値の型を定義するときに使います。後ほど出てきます。

type User = t.TypeOf<typeof User>;

詳しい使い方は公式ドキュメントをご覧ください。

2. .withConverter で Firestore に実行時型検査を挿入

Firestore には Conveter という概念があります。
Conveter については公式ドキュメントに詳細があります。

まず以下のように Converter を定義します。
Converter は toFirestore で Firestore にデータを送るとき、fromFirestore で Firestore からデータを取得するときに任意の操作を差し込むことができます。
これを利用して toFirestorefromFirestore の両方に type.is() を挟むことで実行時に型検査(型ガード)をしています。
もし Firestore から受け取った or に渡したデータが、予め想定したいた型に適合しない場合、例外が発生することですぐに気づくことができます。

conveter.ts
import {
  DocumentData,
  SnapshotOptions,
  QueryDocumentSnapshot,
} from "firebase/firestore";

export const converter = <A extends DocumentData, O, I>(
  // 引数として io-ts で定義した t.typeの値を渡す
  type: t.Type<A, O, I>
) => ({
  // Firestore に保存するとき
  toFirestore: (data: any): DocumentData => {
    if (!type.is(data)) {
      // data が指定された型に一致しなければエラーを投げる
      console.error(data);
      throw new Error("Invalid data type.");
    }
    return data;
  },
  // Firestore から取得するとき
  fromFirestore: (
    snapshot: QueryDocumentSnapshot,
    options?: SnapshotOptions
  ): A => {
    const data = snapshot.data(options);
    if (!type.is(data)) {
      // data が指定された型に一致しなければエラーを投げる
      console.error(data);
      throw new Error("Invalid data type.");
    }
    return data;
  },
});

上記で定義した Converter は以下のように使用できます。
ここでも conveter の恩恵で型推論が効きます。
例ではドキュメントしか示していませんが、コレクションに対しても効かせることができます。

import { collection, addDoc, getDoc, doc } from "firebase/firestore";
import { converter } from "conveter";
import { User } from "types";

// User の追加
const docRef = await addDoc(
  collection(db, "users").withConverter(conveter(User)),
  {
    name: "name 2",
    // ...
    // ここで型推論が効く
  }
);

// User の取得
const docRef = doc(db, `users/${userId}/events`, id).withConverter(
  converter(User)
);
const docSnap = await getDoc(docRef);
const user = docSnap.data();
user.name = "aaa"; // 型推論が効く

3. Cloud Functions for Firebase に型定義をつける

上記ではフロントエンドから Firestore を呼び出す処理に型を効かせました。
次は Cloud Functions for Firebase の引数と返り値にも型を効かせます。
こちらも.is()を用いて、渡された値が想定していた型に適合しない場合にエラーを吐きます。また返り値の型を指定することで、コード変更で返り値の型を意図せず変化させてしまっても気づくことができます。

import * as functions from "firebase-functions";
import { User } from "types";

export const hogehoge = functions.region("asia-northeast1").https.onCall(
  // 返り値の型を指定
  async (data): Promise<t.TypeOf<typeof User>> => {
    if (!User.is(data))
      // 型が正しいくなければエラーを返す
      throw new functions.https.HttpsError(
        "invalid-argument",
        "Invalid User type."
      );

    // なにかの処理

    return {
      name: "user 1",
      age: 12,
      // ...
    };
  }
);

4. フロントエンドの Firebase SDK と Cloud Functions for Firebase の Firebase Admin SDK の型の差異を吸収

実はこのままだと不都合なことがあります。フロントエンドの Firebase SDK と Firebase Admin SDK の型には微妙に差異があるのです。具体的にはTimestampDocumentReferenceなどの型が微妙に違います。
しかしTimestampDocumentReferenceなどを含んだデータ構造をフロントエンドと Firestore のやり取りや、Cloud Functions for Firebase の処理で共通で扱いたいことは多々あります。
そこでこれらの差異を吸収するために以下のような書き方をします。

ここではOrderという注文情報の型を考えてみましょう。
これらは TypeScript の型で表現すれば以下のようになります。

type Order = {
  id: string;
  customerRef: DocumentReference; // Firestore の他のドキュメント参照を表す型
  timestamp: Timestamp; // Firestore の タイムスタンプを表す型
};

今までの流れの通り、今回は io-ts を用いているので、Order型を io-ts のt.Typeの値で表現していきます。

まずcommon.tsにて、引数として io-ts で生成されたDocumentReference Timestampを示すt.Typeの値を受け取り、返り値としてOrder型を示すt.Typeの値を返す関数を定義します。
やりたいことはジェネリック型と同じです。しかし io-ts は型ではなく実行コードなので、関数で再現します。

common.ts
import * as t from "io-ts";

export const getOrder = <T extends t.Mixed, S extends t.Mixed>(
  documentReference: T,
  timestamp: S
) => t.type({
  id: t.string,
  customerRef: documentReference,
  orderdTime: timestamp,
});

フロントでは Firebase SDK のDocumentReference Timestampから io-ts のt.Typeの値であるTimestampIots DocumentReferenceIotsを定義します。
それを先程定義したgetOrderの引数としてあげることで、返り値にフロントで問題なく使えるOrderが返ってきます。

front.ts
import { getOrder } from "../common";
import {
  DocumentReference,
  Timestamp,
} from "firebase/firestore";

// ここらへんの書き方は https://gcanti.github.io/io-ts/ が参考になります。
const TimestampIots = new t.Type<Timestamp, Timestamp, unknown>(
  "Timestamp",
  (u): u is Timestamp => u instanceof Timestamp,
  (u, c) => (u instanceof Timestamp ? t.success(u) : t.failure(u, c)),
  (a) => a
);

const DocumentReferenceIots = new t.Type<
  DocumentReference,
  DocumentReference,
  unknown
>(
  "DocumentReference",
  (u): u is DocumentReference => u instanceof DocumentReference,
  (u, c) => (u instanceof DocumentReference ? t.success(u) : t.failure(u, c)),
  (a) => a
);

// フロントエンドで使える t.typeの値である Order
export const Order = getOrder(
  DocumentReferenceIots,
  TimestampIots
);

無事フロントエンドで使えるOrdert.Typeの値が手に入りましたので、前述のように Converter に噛ませれば以下のように安全に注文情報を Firestore とやり取りできます。

const docRef = await addDoc(
  collection(db, "orders").withConverter(conveter(Order)),
  {
    id: "aaaa",
    // ...
    // ここで型推論が効く
  }
);

Cloud Functions for Firebase の方も同様です。

functions.ts
import { getOrder } from "../common";
import {
  DocumentReference,
  Timestamp,
} from "firebase-admin/firestore";

const TimestampIots = new t.Type<Timestamp, Timestamp, unknown>(
  "Timestamp",
  (u): u is Timestamp => u instanceof Timestamp,
  (u, c) => (u instanceof Timestamp ? t.success(u) : t.failure(u, c)),
  (a) => a
);

const DocumentReferenceIots = new t.Type<
  DocumentReference,
  DocumentReference,
  unknown
>(
  "DocumentReference",
  (u): u is DocumentReference => u instanceof DocumentReference,
  (u, c) => (u instanceof DocumentReference ? t.success(u) : t.failure(u, c)),
  (a) => a
);

export const Order = getOrder(
  DocumentReferenceIots,
  TimestampIots
);

これで、共通化できる部分は共通化しつつも、フロントエンドで使われる Firebase SDK と Cloud Functions for Firebase で使われる Firebase Admin SDK の差異を吸収した型をそれぞれ作成することができました。

実例

https://github.com/heyho-heytechcamp2022/heyho

Discussion