Help us understand the problem. What is going on with this article?

TypeScriptからFirestoreを使いやすくするfirestore-simple v4をリリースしました

追記: v5についてのエントリはこちら

Firestoreのラッパーライブラリであるfirestore-simpleのv4をリリースしました! :tada:

https://github.com/Kesin11/Firestore-simple

v4の主な変更点は、ほぼ全てのメソッドについてTypeScriptの型情報がまともに付くようになりました。VSCodeのように型情報から補完が効くエディタを使っている場合、もうプロパティ名のtypoによるエラーに悩まされることはないはずです。

また、FieldValue.increment()やFieldValue.serverTimestamp()をupdate()で使うことが可能になりました。

そもそも型の改善以前にfirestore-simpleについてご存知ない方がほとんどだと思いますので、まずは全体の雰囲気としてサンプルコードをお見せします。

// TypeScript
import admin, { ServiceAccount } from 'firebase-admin'
import serviceAccount from '../../firebase_secret.json' // prepare your firebase secret json before exec example
import { FirestoreSimple } from 'firestore-simple'

const ROOT_PATH = 'example/usage'

admin.initializeApp({ credential: admin.credential.cert(serviceAccount as ServiceAccount) })
const firestore = admin.firestore()

interface User {
  id: string,
  name: string,
  age: number,
}

const main = async () => {
  // declaration
  const firestoreSimple = new FirestoreSimple(firestore)
  const dao = firestoreSimple.collection<User>({ path: `${ROOT_PATH}/user` })

  // add
  const bobId = await dao.add({ name: 'bob', age: 20 })
  // 3Y5jwT8pB4cMqS1n3maj

  // fetch(get)
  // A return document is typed as `User` automatically.
  const bob: User | undefined = await dao.fetch(bobId)
  // { id: '3Y5jwT8pB4cMqS1n3maj', age: 20, name: 'bob' }

  // update
  await dao.set({
    id: bobId,
    name: 'bob',
    age: 30, // update 20 -> 30
  })

  // delete
  const deletedId = await dao.delete(bobId)
  // 3Y5jwT8pB4cMqS1n3maj

  // multi set
  // `bulkSet` and `bulkDelete` are wrapper for WriteBatch
  await dao.bulkSet([
    { id: '1', name: 'foo', age: 1 },
    { id: '2', name: 'bar', age: 2 },
  ])

  // multi fetch
  const users: User[] = await dao.fetchAll()
  // [
  //   { id: '1', name: 'foo', age: 1 },
  //   { id: '2', name: 'bar', age: 2 },
  // ]

  // multi delete
  await dao.bulkDelete(users.map((user) => user.id))
}

main()

オリジナルのFirestoreをそのまま使う場合と比べると、data()などが不要になっており、全体的に短いコードで済むことが分かると思います。

fetch

オリジナルのFirestoreを使った場合にはcollectionRef.doc('id').get().then((doc) => doc.data())のようにしてドキュメントのデータを取得しますが、これには型の情報が全くありません。そのため、TypeScriptで使う場合にはas SomeTypeと毎回キャストするか、何らかのクラスのインスタンスに中身を詰め直すことで型を付けるようなコードを毎回書くことになります。

firestore-simpleではfetch()でデータを取得すると自動的に型を付けてくれるため、このような煩わしさから開放されます。

// オリジナルのFirestore
const collectionRef = firestore.collection('users')
const user1 = await collectionRef.doc('id').get().then((doc) => {
  return doc.data() as User // キャスト必要
}

// firestore-simpleを使う場合
const firestoreSimple = new FirestoreSimple(firestore)
const dao = firestoreSimple.collection<User>({ path: 'users' })
const user2 = await dao.fetch('id') // キャスト不要で自動的にUser型が得られる

この機能はv2のときから実現できていましたが、現在でもfirestore-simpleの中で最も便利な機能でしょう。

encode/decode

Firestoreにread/writeする際、js <-> Firestoreの間でプロパティ名を変更したり、型の変換をよく行うはずです。例えば時刻データを扱う際に、このようなFirestoreのTimestamp型からjsのDate型に変換するコードを頻繁に書いていないでしょうか?

type User = {
  id: string,
  name: string,
  coinNum: number,
  created: Date,
}

const collectionRef = firestore.collection('users')
const user1 = await collectionRef.doc('id').get().then((doc) => {
  const data = doc.data()
  return {
    ...data,
    ...{ created: data.created.toDate() }, // FirestoreのTimestampをjsのDateに変換
  } as User
})

firestore-simpleはこの変換処理をencodedecodeに定義しておくことでfetch()したときに自動的に変換してくれる機能があります。ですが、decodeを実装するときにはプロパティ名の補完が効かなかったためにtypoしてしまうことがあり、その場合には変換が行われなかったり値がnullになってしまうというバグが起こってしまいます。

これは、v3までのfirestore-simpleではジェネリクスでjs側での型情報しか渡していなかった制約によるものでした。Firestore側での型情報を保持していなかったので、Firestoreからデータを取得したときにdecodeで引数に渡ってくるオブジェクトはanyになってしまっていました。

v4ではFirestoreSimple.collection<T, S>とジェネリクスを拡張することにより、SにFirestore側での型情報を保持することが可能になりました。これにより、型情報を追加で渡せばdecode内でプロパティ名の補完も効くようになりました。

意図的にS = anyとして型情報を落とした場合:

encode_decode_1.gif

S = UserFirestoreとして型情報を渡した場合:

encode_decode_2.gif

後者のfirestoreSimple.collection<User, UserFirestore>とFirestore側の世界での型情報を渡した場合は、decodeの内部でプロパティ名の補完が効いていることが分かると思います。

where()やupdate()における型情報を使った補完の追加

v3まではFirestore側の型情報を保持できなかったため、where()update()update()はv4からの追加)といったFirestore側でのプロパティ名を直接引数に取るようなメソッドも型情報を使って補完することはできませんでした。

前述のようにv4ではFirestore側での型もジェネリクスで渡せるようになりましたので、型情報を使うことでプロパティ名をtypoした場合にTypeScriptがコンパイルエラーを出せるようになりました。当然、補完も効きます。

where_update.gif

このように、firestore-simpleを使用した場合にはFirestoreの多くの操作が型によって安全にガードされます。意図しないプロパティ名や型でFirestoreに保存してしまうというミスや、js側でのtypoをコンパイルエラーによって防ぐことが可能になり開発効率が上がります。

その他のv3, v4の新機能

collectionFactoryによるサブコレクション対応

firestore-simpleはコレクションのパスを最初に決め打ちしてインスタンスを生成することでオリジナルのFirestoreに比べてシンプルなAPIを提供するというコンセプトでした。そのため、例えば/users/${userId}/friendsのようなサブコレクションを作った場合などにおいて、複数のuserIdでforEachが必要なケースなどは逆に使いづらくなっていました。

これを何とかできないかとTypeScriptの型パズルに何度も挑戦しては挫折していたのですが、最終的には型パズルではなくてFactoryのデザインパターンで妥協することにしました。

const firestoreSimple = new FirestoreSimple(firestore)
// 後のループで使うCollectionを定義する
// collectionFactoryはcollectionと異なり、作成時にpathを確定させない
const userFriendFactory = firestoreSimple.collectionFactory<UserFriend, UserFriendFirestore>({
  encode: (obj) => {
    return {
      name: obj.name,
      created: obj.created as firestore.Timestamp,
    }
  },
  decode: (doc) => {
    return {
      id: doc.id,
      name: doc.name,
      created: doc.created.toDate(),
    }
  },
})

const userIds = ['1', '2', '3']
for (const userId of userIds) {
  // pathを後から与えてcollectionのインスタンスを作成する
  const userFriendDao = userFriendFactory.create(`users/${userId}/friends`)

  const friends = await userFriendDao.fetchAll()
  console.log(userId, friends)
}

以前はループの中でFirestoreSimpleCollectionのインスタンスを作成する際に毎回encode, decodeを定義する必要があったのですが、collectionFactoryを使うことでサブコレクションでもencode, decodeの定義が一度で済むようになりました。

update()に対応

以前は 「Firestoreをもっと扱いやすくする」 というfirestore-simpleのコンセプトを重視してため、set()で代用可能なupdate()はあえて実装しない方針でした。ですが、後述するFirestoreの新機能であるFieldValue.increment()に対応するためupdate()を実装しました。

先述したように、型によってプロパティ名のtypoが起きないようになっています。

FieldValue.increment(), FieldValue.serverTimestamp()が可能になった

incrementが可能になったことはFirestore的にはかなり大きなアップデートであり、Firestoreの一部の使い方においては革命的とも言えるものでした。

Cloud Firestoreに追加されたFieldValue.increment()は期待以上!!

firestore-simpleは作り上、FieldValue.increment()は不可能だったのですが、v4で使えるようにする改修を行いました。 使い方はオリジナルのFirestoreでの一般的な使い方と変わらず、update()の中でFieldValue.increment()を使うだけです。

await dao.update({
  id: 'userId',
  coinNum: FieldValue.increment(100),
  created: FieldValue.serverTimestamp(),
})

ちなみに、increment()を可能にするための改修により、Firestoreのサーバー側の時刻を代入するserverTimestamp()も使えるようになりました。

ページング系のメソッドに対応

ページング系と呼んでいるのは以下のメソッドです。

  • startAt()
  • startAfter()
  • endAt()
  • endBefore()

参考: クエリカーソルを使用したデータのページ設定

これらはオリジナルのFirestore同様、where()orderBy()にメソッドチェーンで使用可能になりました。

v5以降の予定について

予定している機能はREADMEの一番最後にチェックリストの形で公開しています。

最優先で考えているのはcollectionGroupへの対応です。これもFieldValue.increment()同様、最近のFirestoreのアップデートの中でも革命的なものですので、当然対応したいと考えています。

あとはadmin SDKではない、ユーザのブラウザで使用されるweb SDKへの対応です。実は自分は今までほとんどadmin SDKしか使ったことがなかったため、この2つのSDKの違いについてほとんど把握できていませんでした。
v1かv2ぐらいの時代にReactNativeで少し遊んでいた時代にfirestore-simpleがそのまま使えていたのでweb SDKでも対応できていると勘違いしてしまっていました。使えなかったというissueを頂いたので改めて調査したところ、この2つのSDKは中身の実装とTypeScriptの型も全くの別物ということが分かりました。

理想としてはFirestoreSimpleの引数に渡すFirestoreのインスタンスをそれぞれのSDKのものに差し替えるだけで動くようにしたかったのですが、残念ながら難しそうです。別のクラスかパッケージとして実装は全くの別物にする必要がありそうなので、対応するにしても少し時間がかかってしまいそうです。

以上、拙作のfirestore-simpleをv4にアップデートした紹介でした。GitHubのREADMEexampleにはそれぞれの機能の詳細な説明やサンプルコードもあります。

オリジナルのFirestoreのAPIが微妙にめんどくさいと感じる方や、TypeScriptから使いにくいと感じていた方はぜひ試してみてください。

Kesin11
最近はFirebaseとTypeScriptに興味あり。qiitaには主にjavascript/typescriptの記事を上げていく予定
https://github.com/Kesin11
dena_coltd
    Delight and Impact the World
https://dena.com/jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away