LoginSignup
34
9

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

Last updated at Posted at 2022-03-31

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

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

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

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

※繰り返し


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

はじめに

フロントエンジニアとして活動させていただいております。
ふぁると申します!

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

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

Remixについて
Reactベースの新フルスタックフレームワーク「Remix」を読み解いてみた

良ければフォロー、starお願いします!
Twitter
@fal_engineer

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

概要

TypeScriptでFirestoreのデータを取得、作成、更新する際の、converterを使った型安全の保証の方法についてを記述します。
具体的にはこちらのリポジトリで実践した技術です。

この記事を読みわかること

  • Firestoreとの通信において、型安全を保証する方法
  • FirestoreDataConverterとは
  • FirestoreDataConverterの使い方
  • FirestoreDataConverterを使い出来ること

この記事では書かなないこと

  • TypeScript, reactの基本構文
  • Firebase,Firestoreの基本的な使い方

Firestoreからの取得方法

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

Firestoreのデータ構造を以下のものと仮定します。

(collection)
  example   (document)
        └─── doc
              ├ id:number
              ├ name:string
              └ description:string
( async() => {
  const snap = doc(db, "example/doc");
  const doc = await getDoc(snap);
  const docData = doc.data();
});

しかしこの場合の変数「docData」の型はanyなので、以下は型エラーとなります。

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を省く(型ガードを行う)には、以下のような方法があります。

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型として扱いたい場合は、型アサーションを行う必要があります。

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

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
}

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

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

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

Postクラスのコンストラクタで、Postクラスの引数を定義しています。
またtoStringメソッドは(第一引数) + ', by ' + (第二引数)を返します。

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内ではこのように定義されております。

    /**
     * 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
コードを見てみます。

    /**
     * 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の変数の末尾の"!"(エクスクラメーション/感嘆符)の意味

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一つを使いまわせます。

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
};

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

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;
  },
});
34
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
9