667
0

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()