お願いがあるのよ TypeScriptで扱うデータの型定義
大事に思うならば ちゃんと聞いてほしい
Firestoreにpostするデータも 型定義無いのはまだ許すけど
getしたデータ anyだからってアサーションしないで
※FirestoreとTypeScriptと私 永続層とコードの整合性と保守性のため
型安全保障しておきたいから※
unknown放置しないでね 未来のあなたのため
型安全でいさせて
いつわらないでいて 女の勘は鋭いもの
コンパイラに嘘を吐くとき 右の眉が上がる
あなた無闇にasしたら 将来のコードに気を付けて
私は知恵をしぼって 総リファクタリングで一緒に逝こう
※繰り返し
本当はpostするときも型定義はしっかりしたいものですね。
はじめに
フロントエンジニアとして活動させていただいております。
ふぁると申します!
TypeScript、Vue.js、Reactを主に使って、会社員やりつつ、趣味で個人開発を行っております。
サービス公開時の技術記事リンク
学生エンジニアのためのチャットサービスをNext.js + TypeScript + AtomicDesign + Firebase9 + Dockerで作った
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;
},
});