🔥

TypeScript × Firestoreの「日付の型」についてのアイディア

2022/04/04に公開

はじめに

TypeScriptとFirestoreの開発はよくあるパターンだと思います。フロントもバックエンドも同じ言語で書けるところも好きで、僕もよくやる選択肢の一つです。
しかし、その中でも毎回迷うのが「型情報」の取り扱い。特に日付に関しては悩みの種になりがちですよね。そこをnumberで扱うことによってよりシンプルなデータの受け渡しになるのではないかと思ってこの記事で紹介しました。

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

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でエラーになる)

実際の型
type UserObject = {
  id: string;
  name: string;
  email: string;
  registeredAt: number;
  favorites: {id: string, timestamp?: number}[]
};

ロジック

FirestoreのQueryを有効に利用したいため、Timestamp型を利用します。
(ロジックにFirestoreの型を露出したくないパターなんなどは適宜読み替えてください。)

実際の型
type UserModel = {
  id: string;
  name: string;
  email: string;
  registeredAt: firestore.Timestamp;
  favorites: {id: string, timestamp?: firestore.Timestamp}[]
};

データベースアクセス

DBへの直接アクセスするレイヤーをイメージしています。メタデータはここでだけ型として登場します。Timestamp型を直接利用します。

実際の型
type UserDocument = {
  id: string;
  name: string;
  email: string;
  registeredAt: firestore.Timestamp;
  favorites: {id: string, timestamp?: firestore.Timestamp}[];
  createdAt: firestore.Timestamp;
};

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

Firestoreのアクセス時に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に縛られることなくフロントで好きなライブラリを利用することができます

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

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

export type EpochMillis = number;
type isTimestamp<T> = T extends FirebaseFirestore.Timestamp
  ? T
  : T extends FirestoreTimestampType | undefined
  ? EpochMillis | undefined
  : never;

export type DeepTimestampToMillis<T> = T extends Array<infer R>
  ? Array<DeepTimestampToMillis<R>>
  : T extends FirebaseFirestore.Timestamp
  ? 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;

export const deepTimestampToMillis = <T>(data: T): DeepTimestampToMillis<T> => {
  if (data instanceof Array) {
    return data.map(deepTimestampToMillis) as DeepTimestampToMillis<T>;
  } else if (data instanceof Timestamp) {
    return data.toMillis() as DeepTimestampToMillis<T>;
  } else if (Object.prototype.toString.call(data) === '[object Object]') {
    return Object.entries(data).reduce((prev, [k, v]) => {
      if (v instanceof Timestamp) {
        prev[k] = v.toMillis();
      } else {
        prev[k] = deepTimestampToMillis(v);
      }
      return prev;
    }, {} as Record<string, unknown>) as DeepTimestampToMillis<T>;
  } else {
    return data as DeepTimestampToMillis<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は更新したくないのでフィールドから消す
  '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://stackoverflow.com/questions/47914536/use-partial-in-nested-property-with-typescript

終わりに

おそらく「型から消すのは乱暴すぎるでしょ」という意見もたくさんあると思います。しかし、メタデータとアプリケーションデータは明確に区別するべきとシチュエーションもあるので(仮にアプリケーションで「ユーザ登録日」を利用するのあれば明示的に設定すべき)、それに従った設計としています。

今回はFirestoreにかなりフォーカスして書いていますが、大事なことは「レイヤーそれぞれで使いやすい型に変更」することだと思っており、TypeScriptはかなり柔軟にそれを叶えてくれます。

Discussion