The Problem
Something that’s been on my TODO for a while now has been to figure out a slick and clean way of using Firestore in a more type-safe way. I was fed up of doing:
const someData = firebaseDocumentRef.data() as SomeType
I have to import the type in each file I use it. What if I get the type wrong? More code to write etc. etc.
Something else that gives me the chills is having to write the collection names all over the place (and spell them correctly).
// file/somewher.ts
const userCollection = firestore().collection('users')
// file/somewhere/else.ts
const userCollection = firestore().collection('users')
// file/going/to/make/a/bug.ts
const userCollection = firestore().collection('usrs')
Even in a small project, this quickly gets out of hand. I need to remember collection names, spell them correctly and write lots of boiler. And what happens if you decide to change a collection name down the line. Nightmare.
The Solution:
I’ve gone around the houses with a solution to this. The obvious start is to abstract away the Firestore library into a helper function, but then we need to wrap all of the functions that it provides and add types to them. This seems a bit over-the-top and means we need to keep up to date with changes to the Firestore SDK.
I started just solving the issue of keeping track of the collection names by maintaining an object that I import when I want to access the Firestore database:
// utils/db.ts
const db = {
users: firestore().collection('users')
}
export { db }
// some/other/file
import { db } from '~/utils/db'
import { User } from '~/@types'
const example = async () => {
const userQuery = await db.users.get()
const users = userQuery.docs.map(x => x.data() as User)
}
This works quite well, but you see we still need to manually cast types to everything.
Then I found the magic method. Firestore’s .withConverter()
const augmentedCollection = firestore()
.collection('users')
.withConverter(converterFunction) // more on this in a mo
I’ve not read about this method in the docs — it’s probably in there, but as a bit of a Firestore veteran, I don’t look at them often at all. It’s easily missed and seems almost useless… Until you bring in Typescript!
The withConverter method applies a custom data converter to a firestore Query (ie a collection reference). The method takes a single argument which is a FirestoreDataConverter object. This object should have two keys, both of which are functions:
toFirestore
fromFirestore
The toFirestore function gets passed whatever object you pass to the set or update functions as it’s first and only argument:
// newUser is passed to the toFirestore function
firestore().collection('users').doc('1').set(newUser)
// updatedUser is passed to the toFirestore function firestore().collection('users').doc('1').update(updatedUser)
The fromFirestore method receives the data returned from awaiting a get function — the object usually accessed with .data()
const doc = firestore().collection('users').doc('1').get()
// data here will be what is available in the fromFirestore function
const data = doc.data()
With this knowledge we could set the Types for a query in a very clean way like so:
import { User } from '~/@types'
const converter = {
toFirestore: (data: User) => data,
fromFirestore: (snap: FirebaseFirestore.QueryDocumentSnapshot) =>
snap.data() as User
}
const doc = firestore()
.collection('users')
.withConverter(converter)
.doc('1').get()const user = doc.data() // user will be typed! Wooohooo!
// it even checks the type passed to a set or update function
await firestore()
.collection('users')
.withConverter(converter)
.doc('1')
.set({
userName: 'Jamie',
someBadKey: false // Type Error!
})
This is pretty awesome! All we need to do now is abstract it out. I made a few functions to help us do this. The first is a generic converter function that will take a type argument and create a typed FirestoreDataConverter object:
// utils/db
import { firestore } from "firebase-admin"
const converter = <T>() => ({
toFirestore: (data: T) => data,
fromFirestore: (snap: FirebaseFirestore.QueryDocumentSnapshot) =>
snap.data() as T
})
The next part is a function that abstracts out the lengthy path to a collection reference with a data converter. It takes the path to the collection as a string as it’s first and only argument. It also has a Type argument which will end up being passed through to the firestore data converter:
// utils/db
// ...
const dataPoint = <T>(collectionPath: string) => firestore().collection(collectionPath).withConverter(converter<T>())
We can then use the dataPoint function to create our db object and magically have types!
// utils/db
// ...
import { User } from '~/@types'
const db = {
users: dataPoint<User>('users')
}
export { db }
We can then use this db object in all our files and know that our types will be safe :
import { db } from '~/utils/db'
// firestore just as you know it, but with types
const userDoc = await db.users.doc(id).get()
const {
userName: 'Jamie',
someBadKey: false // Type Error!
} = userDoc.data()
await db.users.doc(id).set({
userName: 'Jamie',
someBadKey: false // Type Error!
})
When you want to add more database collections to the db object, simply add another key with a dataPoint function:
// utils/db
// ...
import { User, Post } from '~/@types'
const db = {
users: dataPoint<User>('users'),
posts: dataPoint<Post>('posts')
}
export { db }
If you want to target a subcollection in Firestore that may have a dynamic path, you can just write a function in the db object:
// utils/db
// ...
import { User, Post, UserPost } from '~/@types'
const db = {
users: dataPoint<User>('users'),
posts: dataPoint<Post>('posts'),
userPosts: (userId: string) => dataPoint<UserPost>(`users/${userId}/posts`)
export { db }
And use it like this:
import { db } from '~/utils/db'
const userPost = await db.userPosts('1').doc('postid123').get()
const post = userPost.data()
That’s about as far as I’ve got for now — if you have any questions or comments or enhancements please don’t forget to leave a clap or a comment
The whole file for the db util looks like this:
// import firstore (obviously)
import { firestore } from "firebase-admin"
// Import or define your types
// import { YourType } from '~/@types'
const converter = <T>() => ({
toFirestore: (data: Partial<T>) => data,
fromFirestore: (snap: FirebaseFirestore.QueryDocumentSnapshot) => snap.data() as T
})
const dataPoint = <T>(collectionPath: string) => firestore().collection(collectionPath).withConverter(converter<T>())
const db = {
// list your collections here
// users: dataPoint<YourType>('users')
}
export { db }
export default db
Thanks for reading.
Source: Medium
The Tech Platform
Comments