FirestoreとTypeScriptと私【withConverterについて】


---
お願いがあるのよ TypeScriptで扱うデータの型定義
大事に思うならば ちゃんと聞いてほしい
Firestoreにpostするデータも 型定義無いのはまだ許すけど
getしたデータ anyだからってアサーションしないで

※FirestoreとTypeScriptと私 永続層とコードの整合性と保守性のため
型安全保障しておきたいから※

unknown放置しないでね 未来のあなたのため
型安全でいさせて

いつわらないでいて 女の勘は鋭いもの
コンパイラに嘘を吐くとき 右の眉が上がる
あなた無闇にasしたら 将来のコードに気を付けて
私は知恵をしぼって 総リファクタリングで一緒に逝こう

※繰り返し

---

**本当はpostするときも型定義はしっかりしたいものですね。**

# はじめに
フロントエンジニアとして活動させていただいております。
ふぁると申します！

TypeScript、Vue.js、Reactを主に使って、会社員やりつつ、趣味で個人開発を行っております。
**サービス公開時の技術記事リンク**
[学生エンジニアのためのチャットサービスをNext.js + TypeScript + AtomicDesign + Firebase9 + Dockerで作った](https://qiita.com/Itamaster/items/9e2c1772596ebc4415ce)

[【個人開発】（Nuxt.js + Rails）OSSやプロジェクトの、ソースコードを管理するGitHubのように、web上で結合テストをチームで同時に管理・実行・記録出来るプラットフォームを開発しました！](https://qiita.com/Itamaster/items/f821be4c33caab640a93)

**Remixについて**
[Reactベースの新フルスタックフレームワーク「Remix」を読み解いてみた](https://qiita.com/Itamaster/items/5f44dc785f3faf268fb6)

良ければフォロー、starお願いします！
**Twitter**
[@fal_engineer](https://twitter.com/fal_engineer)

**GitHub**
https://github.com/FAL-coffee

# 概要
TypeScriptでFirestoreのデータを取得、作成、更新する際の、converterを使った型安全の保証の方法についてを記述します。
具体的には[こちら](https://github.com/FAL-coffee/hacker-class-room)のリポジトリで実践した技術です。

## この記事を読みわかること
- Firestoreとの通信において、型安全を保証する方法
- FirestoreDataConverterとは
- FirestoreDataConverterの使い方
- FirestoreDataConverterを使い出来ること

## この記事では書かなないこと
- TypeScript, reactの基本構文
- Firebase,Firestoreの基本的な使い方

# Firestoreからの取得方法
Firestoreの9系では、以下のようにして情報を取得します。

Firestoreのデータ構造を以下のものと仮定します。
```
(collection)
  example   (document)
        └─── doc
              ├ id:number
              ├ name:string
              └ description:string
```

```ts
( async() => {
  const snap = doc(db, "example/doc");
  const doc = await getDoc(snap);
  const docData = doc.data();
});
```
しかしこの場合の変数「docData」の型はanyなので、以下は型エラーとなります。

```ts
interface dataType {
  id:number;
  name:string;
  description:string;
};

( async() => {
  const docRef = doc(db, "example/doc");
  const docSnap = await getDoc(docRef);
  const docData = docSnap.data();
  const data: dataType = docData; // error!（docDataの型はDocumentData | undefinedであるため）
});
```

undefinedを省く（型ガードを行う）には、以下のような方法があります。

```ts
interface dataType {
  id:number;
  name:string;
  description:string;
};

( async() => {
  const docRef = doc(db, "example/doc");
  const docSnap = await getDoc(docRef);
  if (!docSnap.exists()) return; 
  const docData = docSnap.data(); // 型ガードにより、docData: DocumentDataとなる
  const data: dataType = docData; // error!（dataType 型にDocumentData 型を代入できないため）
});
```

このままのコードでdocDataをdataType型として扱いたい場合は、型アサーションを行う必要があります。

```ts
interface dataType {
  id:number;
  name:string;
  description:string;
};

( async() => {
  const docRef = doc(db, "example/doc");
  const docSnap = await getDoc(docRef);
  if (!docSnap.exists()) return; 
  const docData = docSnap.data(); 
  const data: dataType = docData as dataType; 
});
```

これでエラーは消えますが、芸術点が低いですね。
アサーションはなるべくない方が良いに決まっています。
（型安全性を低めてしまうため）
では、どうすればよいのでしょう。

# FirestoreDataConverterとは
FirestoreDataConverterとは、Firestoreとの通信時にデータを変換するために用いられるdoc,collectionのwithConverter()プロパティに使用されるコンバータオブジェクトです。
簡素に言うと、withConverterオブジェクトの引数としてFirestoreDataConverterを渡すことで、Firestoreに渡すデータや返り値に対し、型を保証する事が可能になります。

> Converter used by withConverter() to transform user objects of type T into Firestore data.
Using the converter allows you to specify generic type arguments when storing and retrieving objects from Firestore.

> コンバータを使用すると、Firestoreからオブジェクトを保存したり取得したりする際に、一般的な型引数を指定することができます。

## 公式Example
```ts
class Post {
  constructor(readonly title: string, readonly author: string) {}

  toString(): string {
    return this.title + ', by ' + this.author;
  }
}

const postConverter = {
  toFirestore(post: Post): firebase.firestore.DocumentData {
    return {title: post.title, author: post.author};
  },
  fromFirestore(
    snapshot: firebase.firestore.QueryDocumentSnapshot,
    options: firebase.firestore.SnapshotOptions
  ): Post {
    const data = snapshot.data(options)!;
    return new Post(data.title, data.author);
  }
};

const postSnap = await firebase.firestore()
  .collection('posts')
  .withConverter(postConverter)
  .doc().get();
const post = postSnap.data();
if (post !== undefined) {
  post.title; // string
  post.toString(); // Should be defined
  post.someNonExistentProperty; // TS error
}
```

噛み砕いて見ていこうかと思います。

```ts
class Post {
  constructor(readonly title: string, readonly author: string) {}

  toString(): string {
    return this.title + ', by ' + this.author;
  }
}
```
Postクラスのコンストラクタで、Postクラスの引数を定義しています。
またtoStringメソッドは```(第一引数) + ', by ' + (第二引数)```を返します。

```ts
const postConverter = {
  toFirestore(post: Post): firebase.firestore.DocumentData {
    return {title: post.title, author: post.author};
  },
  fromFirestore(
    snapshot: firebase.firestore.QueryDocumentSnapshot,
    options: firebase.firestore.SnapshotOptions
  ): Post {
    const data = snapshot.data(options)!;
    return new Post(data.title, data.author);
  }
};
```
こいつがFirestoreDataConverterです。
FirestoreDataConverterオブジェクトはtoFirestore,fromFirestoreの二つのメソッドを持ちます。

**toFirestore**
それぞれについてですが、SDK内ではこのように定義されております。
```ts
    /**
     * Called by the Firestore SDK to convert a custom model object of type `T`
     * into a plain JavaScript object (suitable for writing directly to the
     * Firestore database). To use `set()` with `merge` and `mergeFields`,
     * `toFirestore()` must be defined with `PartialWithFieldValue<T>`.
     *
     * The `WithFieldValue<T>` type extends `T` to also allow FieldValues such as
     * {@link (deleteField:1)} to be used as property values.
     */
    toFirestore(modelObject: WithFieldValue<T>): DocumentData;
    /**
     * Called by the Firestore SDK to convert a custom model object of type `T`
     * into a plain JavaScript object (suitable for writing directly to the
     * Firestore database). Used with {@link (setDoc:1)}, {@link (WriteBatch.set:1)}
     * and {@link (Transaction.set:1)} with `merge:true` or `mergeFields`.
     *
     * The `PartialWithFieldValue<T>` type extends `Partial<T>` to allow
     * FieldValues such as {@link (arrayUnion:1)} to be used as property values.
     * It also supports nested `Partial` by allowing nested fields to be
     * omitted.
     */
    toFirestore(modelObject: PartialWithFieldValue<T>, options: SetOptions): DocumentData;
```
firestoreに対して書き込みを行う時に呼び出される処理で、DocumentDataを返すよーみたいな感じですね。
Example中ではPostのコンストラクタを返しているだけですが、処理を挟み込めるため、Date型があればfirestore.Timestampに変換するとか、数値があれば文字列型に変換するとか、単位を付けるとか、そんな感じの便利処理を加えることができます。

**fromFirestore**
コードを見てみます。
```ts
    /**
     * Called by the Firestore SDK to convert Firestore data into an object of
     * type T. You can access your data by calling: `snapshot.data(options)`.
     *
     * @param snapshot - A `QueryDocumentSnapshot` containing your data and metadata.
     * @param options - The `SnapshotOptions` from the initial call to `data()`.
     */
    fromFirestore(snapshot: QueryDocumentSnapshot<DocumentData>, options?: SnapshotOptions): T;
}
```
データ取得時に呼び出されて、snapshotやoptionがあれば受け取って、doc.data()がTになるように返すよーって感じです。
Exampleでは、fromFirestoreの返り値がPostのコンストラクタを返すようになっています。
もちろんこちらも処理を挟み込めるため、firestore.TimestampをDateとして受け取るとかが出来ます。

[TypeScriptの変数の末尾の"!"(エクスクラメーション/感嘆符)の意味](https://qiita.com/zigenin/items/364264a6cf635b962542)

```ts
const postSnap = await firebase.firestore()
  .collection('posts')
  .withConverter(postConverter)
  .doc().get();
const post = postSnap.data();
if (post !== undefined) {
  post.title; // string
  post.toString(); // Should be defined
  post.someNonExistentProperty; // TS error
}
```
postSnapにfirestoreからコレクション内の最新ドキュメントを取得していますが、
コレクションに対してwithConverterにpostConverterを渡しています。
そうすることにより、postSnap.data().titleがstringとなることが確認出来ます。
（型補完も効きます。）
アサーションを行わずとも、型安全を保証出来ました。

ちなみに公式Exampleでは型をあらかじめ定義していますが、ジェネリクスを使うことでconverter一つを使いまわせます。
```ts
export const converter = <T>(): FirestoreDataConverter<T> => ({
  toFirestore: (data: WithFieldValue<T>) => {
    return data;
  },
  fromFirestore: (snapshot: QueryDocumentSnapshot<T>, option) => {
    const data = snapshot.data();
    return data;
  },
});

interface hoge {
  id:number;
  hogehoge:string;
}

interface hage {
  id:string;
  hagehage:string;
}

( async() => {
  const hoge = doc(db,"hoge/hoge").withConverter(converter<hoge>());
  const hogeDoc = await getDoc(hoge)
  const hogeData = hogeDoc.data(); // hogeData: hoge
  // hogeData.id => number
  // hogeData.hogehoge => string
  // hogeData.hagehage => TS Error

  const hage = doc(db,"hage/hage").withConverter(converter<hage>());
  const hageDoc = await getDoc(hage)
  const hageData = hageDoc.data(); // hageData: hage
  // hageData.id => string
  // hageData.hogehoge => TS Error
  // hageData.hagehage => string
};
```

また、こんなんも出来ます。

```ts
interface DocumentSnapshotType {
  [key: string]: any | Timestamp | Date;
}

export const converter = <
  T extends DocumentSnapshotType
>(): FirestoreDataConverter<T> => ({
  toFirestore: (data: WithFieldValue<T>) => {
    Object.keys(data).forEach((key) => {
      // Date型の値をTimestamp型に変換する
      if (
        typeof data[key].toString == "function" &&
        typeof data[key].toString().call == "function" &&
        data[key].toString().call(new Date())
      ) {
        (data as DocumentSnapshotType)[key] = Timestamp.fromDate(data[key]);
      }
    });
    return data;
  },
  fromFirestore: (snapshot: QueryDocumentSnapshot<T>) => {
    const data = snapshot.data();
    Object.keys(data).forEach((key) => {
      // Timestamp型の値をDate型に変換する
      if (
        typeof data[key].toString == "function" &&
        data[key].toString().startsWith("Timestamp")
      ) {
        (data as DocumentSnapshotType)[key] = data[key].toDate();
      }
    });

    return data;
  },
});
```