TypeScriptとFirestoreの「型」と「日付」についての戦略


はじめに

TypeScriptとFirestoreの開発はよくあるパターンだと思います。フロントもバックエンドも同じ言語で書けるところも好きで、僕もよくやる選択肢の一つです。
しかし、その中でも毎回迷うのが「型情報」の取り扱い。
今回は特にFirestoreのTimestampや、createdAtのようなメタ情報の型をうまく扱う方法を考えてみました!

「補完」を前提に型戦略を考える

TypeScriptの高い開発体験を支える大きな一つが「補完」です(断言)
今回は型を考えるにあたって、「補完」をめちゃくちゃ重視します。補完とはVSCodeやIntelliJがやってくれるアレです。
以下に効率よく型情報を取り扱うかという点において、以下の2つを主軸に戦略を考えていきます。

  • 意識しなくても必要な型がつくようにする
  • (実際のデータにあったとしても)不要なフィールドであれば型情報に載せない

キーポイントは大きく2つ

1/ View(フロント)では「Timestampではなく、EpochMillisで扱う」

EpochMillisとはUnix時間のミリ秒のことです。Date型なのかTimestamp型なのかいちいち考えなくてよくなります(もちろん、フロントの型情報でFirestoreに依存しなくてよいなどもありますが)

2/ メタ情報(createdAt/updatedAt)は「フロントの型から消す」

全てDBに保存するレイヤーのみで、型とフィールドを登場させます。もし仮に、「クエリでcreatedAtを使いたい」というケースに対しては直接型をつけるイメージです

フロント

Timestampはフロントでは扱いにくいので、全て「number」として扱います。こうすることで、JavaScriptのDate型やluxonなどの日付ライブラリでかなり扱いやすくなります。
また、createdAtやupdatedAtは直接触ることはないので型からも消します(補完もされず、使おうとしてもLinterでエラーになる)

ロジック

FirestoreのQueryを有効に利用したいため、Timestamp型を利用します。
(Repositoryクラスなどで抽象化する場合などは適宜読み替えてください。)

DB

DBへの直接アクセスするレイヤーをイメージしています。メタデータはここでだけ型として登場します。
もちろんDBに依存していいので、Timestamp型を直接利用します(≒実際のドキュメントと乖離しない型にします)

1/ View(フロント)では「Timestampではなく、EpochMillisで扱う」

最もシンプルなパターンだとこんなイメージになります。
getByIdという関数でFirestoreへのアクセスを行ない、その中の「deepTimestampToMillis」関数でTimestamp -> EpochMillisへの置換を行なっています。

const getById = async <T>(ref: CollectionReference<T>, id: string) => {
  const doc = await ref.doc(id).get();
  const data = doc.exists ? doc.data() : undefined;
  if (!data) return;
  return deepTimestampToMillis<T>({...data, id: doc.id});
};

type UserType = {
  id: string;
  name: string;
  email: string;
  registeredAt: Timestamp; // FirestoreのTimestampクラスのことです
  favorites: {id: string, timestamp?: Timestamp}[]
};

const user = await getById(firestore().collection('users') as firestore.CollectionReference<UserType>, 'userid');

VSCodeでの型情報

いい感じにTimestampがnumberになっていますね!
このようにすることで、Firestoreに縛られることなくフロントで好きなライブラリを利用することができます

上で出てきた「deepTimestampToMillis」はこのような実装になっています。

再帰的なオブジェクトの変換処理に関しては、こちらの記事を参考にさせていただきました。
https://qiita.com/ryo2132/items/4bedeec846d0427f1ac7#木構造を取り扱う処理
https://off.tokyo/blog/typescript-saiki-utility-types/#DeepRequired


export type FirestoreTimestampType = FirebaseFirestore.Timestamp;
export type EpochMillis = number;

type isTimestamp<T> = T extends FirestoreTimestampType
  ? T
  : T extends FirestoreTimestampType | undefined
  ? EpochMillis | undefined
  : never;

export type DeepTimestampToMillis<T> = T extends Array<infer R>
  ? Array<DeepTimestampToMillis<R>>
  : T extends FirestoreTimestampType
  ? EpochMillis
  : T extends Record<string, any>
  ? {
      [P in keyof T]: T[P] extends isTimestamp<T[P]>
        ? EpochMillis
        : T[P] extends Array<infer R>
        ? Array<DeepTimestampToMillis<R>>
        : T[P] extends Record<string, any>
        ? DeepTimestampToMillis<T[P]>
        : T[P];
    }
  : T;

2/ メタ情報(createdAt/updatedAt)は「フロントの型から消す」

作成・更新時にのみ型情報として現れるようにします。メタ情報を消すモチベーションとしては、開発目的だけで利用するフィールドなどを補完させなくすること(≒アプリケーションロジックに必要なフィールドだけを型に出すこと)が最も大きいです。

関数


// FirestoreにSetする前に呼び出す関数
const prepareSet = <T>(data: T) => {
  const setData = {...data} as NestedPartial<WithMetadata<T>>;

  'id' in setData && delete setData.id;
  // createdAtは更新したくないのでフィールドから消す![](https://storage.googleapis.com/zenn-user-upload/f68cf4a970ab-20220404.png)
  'createdAt' in setData && delete setData.createdAt;

  setData.updatedAt = firestore.Timestamp.now();
  return setData as NestedPartial<T>;
};

使い方

const update = async <T>(ref: CollectionReference<T>, id: string, data: NestedPartial<T>) => {
  return await ref.doc(id).set(prepareSet(data) as T, {merge: true});
};

NestedPartialはこちらを参考にさせていただきました
https://tech-1natsu.hatenablog.com/entry/2018/07/07/233655

終わりに

おそらく「型から消すのは乱暴すぎるでしょ」という意見もたくさんあると思います。しかし、個人的にはメタデータとアプリケーションデータは明確に区別するべきという思いを持っており(仮にアプリケーションで「ユーザ登録日」を利用するのあれば明示的に設定すべき)、それに従った設計としています。
今回はFirestoreにかなりフォーカスして書いていますが、大事なことは「レイヤーそれぞれで使いやすい型に変更(あるいは削除)」することだと思っており、TypeScriptはかなり柔軟にそれを叶えてくれます。
特に日付に関しては悩みの種になりがちですよね。。。そこをnumberで扱うことによってよりシンプルなデータの受け渡しになるのではないかと思ってこの記事で紹介しました。
シンプル・統制が取れたコードを目指す上で、「いかに補完を利用するか」はかなり大事だと感じています。自分はこうやってるよ!などのご意見あればぜひ教えてください!