Since the release of firebase v9, the bundle size of the client has reduced substantially since now the functions that are used are the only ones being imported and bundled.
But this modularity comes with its own trade offs. The code becomes cluttered if its complex and is utlizing more firebase functions. In order to tackle this issue, a helper class is created in typescript that is type safe and provides a much cleaner and readable way of utilizing firebase functions.
For this article, we will only be dealing with firestore.
lets first import the functions that will be used in this custom firestore class and intialize the firebase app and firestore database.
import { FirebaseApp, initializeApp } from 'firebase/app'
import {
DocumentReference,
doc,
getDoc,
setDoc,
updateDoc,
deleteDoc,
CollectionReference,
collection,
addDoc,
getDocs,
query,
QueryDocumentSnapshot,
where,
WhereFilterOp,
orderBy,
OrderByDirection,
limit,
startAfter,
getCountFromServer,
QueryFieldFilterConstraint,
QueryOrderByConstraint,
QueryLimitConstraint,
getFirestore,
Firestore,
} from 'firebase/firestore'
let database: Firestore
let app: FirebaseApp
if (typeof window !== 'undefined') {
app = initializeApp({
/* your config */
})
database = getFirestore(app)
}
The previous firestore implementation had mainly two parts ( from a high level perspective ). It has collections and these collections have documents. So we need to create two classes one for Collection
and the other for Document
.
Since we also need type safety, lets start of by defining what the interfaces for these look like
interface Collection1 {
name: string
email: string
// Define other properties
}
interface Collection2 {
message: string
timestamp: number
// Define other properties
}
interface Collections {
collection1: Collection1
collection2: Collection2
}
interface SubCollections {
collection1: {
subCollection1: { field: string }
subCollection2: { field: string }
}
subCollection1: {
sub_c_1: { field: string }
}
subCollection2: {
sub_c_2: { field: string }
}
}
Now that the interfaces have been defined, there might be some confusion around why the Collection
& SubCollections
interface is created in such a way. This is for using generics in typescript which are used in the classes defined below for type safety.
N -> Name
, T -> Type
, S -> SubCollection
class Document<N, T> {
ref: DocumentReference<T>
constructor(id: string) {
this.ref = doc(database, id) as DocumentReference<T>
}
// @ts-ignore
collection<S extends keyof SubCollections[N]>(path: S) {
// @ts-ignore
return new Collection<S, SubCollections[N][S]>(`${this.ref.path}/${String(path)}`)
}
async get(): Promise<T> {
const docSnapshot = await getDoc(this.ref)
if (docSnapshot.exists()) return docSnapshot.data()!
throw new Error('Document does not exist')
}
set(data: T) {
return setDoc(this.ref, data)
}
update(data: Partial<T>) {
// @ts-ignore
return updateDoc(this.ref, data)
}
delete() {
return deleteDoc(this.ref)
}
}
class Collection<N, T> {
ref: CollectionReference<T>
private whereConstraint: QueryFieldFilterConstraint[] = []
private orderByConstraint: QueryOrderByConstraint[] = []
private limitConstraint: QueryLimitConstraint[] = []
private lastDoc: QueryDocumentSnapshot<T> | null = null
constructor(path: string) {
this.ref = collection(database, path) as CollectionReference<T>
}
doc(id: string) {
return new Document<N, T>(`${this.ref.path}/${id}`)
}
add(data: T) {
return addDoc(this.ref, data)
}
getAll(): Promise<T[]> {
return getDocs(this.ref).then((snapshot) => snapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as T)))
}
where<K extends keyof T>(field: K, operator: WhereFilterOp, value: T[K]) {
// @ts-ignore
this.whereConstraint.push(where(field, operator, value))
return this
}
orderBy(field: keyof T, direction: OrderByDirection) {
// @ts-ignore
this.orderByConstraint = [orderBy(field, direction)]
return this
}
limit(size: number) {
this.limitConstraint = [limit(size)]
return this
}
async query(): Promise<T[]> {
const constraints = [...this.whereConstraint, ...this.orderByConstraint, ...this.limitConstraint]
if (this.lastDoc) constraints.push(startAfter(this.lastDoc) as any)
const querySnapshot = query(this.ref, ...constraints)
const snapshot = await getDocs(querySnapshot)
this.lastDoc = snapshot.docs[snapshot.docs.length - 1]
return snapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as T))
}
async count(): Promise<number> {
const querySnapshot = query(this.ref, ...this.whereConstraint)
const snapshot = await getCountFromServer(querySnapshot)
return snapshot.data().count
}
}
Now that the Collection
and Document
classes have been created, we can create a custom store class
class Store {
collection<N extends keyof Collections>(path: N) {
return new Collection<N, Collections[N]>(path)
}
}
Example usage
const store = new Store()
// Add a new document to the collection
store.collection('collection1').add({ name: 'name', email: 'email' })
// Create a new document with a custom id
store.collection('collection1').doc('docId').set({ name: 'name', email: 'email' })
// Access a document within a subcollection
store.collection('collection1').doc('docId').collection('subCollection1').doc('sub_docId').collection('sub_c_1').get()