Firebase Generic withConverter Typescript

Viewed 1099

I'm using firebase to interact with firstore and am taking advantage of the "withConverter" method to handle the mapping of data to and from my domain objects. I want to try and eliminate having to write custom withConverter logic for all my domain objects but keep getting typescript errors when trying to create a generic version. I'm not sure it's even possible.

I want to do something like

export function collectionWithConverter<T,D>(TCreator:{new ():T},ref: CollectionReference<DocumentData>): CollectionReference<T> {
  return ref.withConverter({
    toFirestore: (obj: T) => {
      return obj.toFirestoreJSON()
    },
    fromFirestore: (snapshot: QueryDocumentSnapshot, options: SnapshotOptions) => {
      const data = snapshot.data(options) as D
      return new TCreator({...data, "id": snapshot.id})
    }})
}

But I get errors: 'data' is declared but its value is never read.ts(6133) Type 'CollectionReference' is not assignable to type 'CollectionReference'. Type 'DocumentData' is not assignable to type 'T'. 'T' could be instantiated with an arbitrary type which could be unrelated to 'DocumentData'

export enum ItemCollectionFields {
  ID = "id",
  ITEM_ID = "itemID",
  NAME = "name",
  DESCRIPTION = "descriptionText",
}

const ICF = ItemCollectionFields

export interface ItemProps {
  [ICF.ID]: string,
  [ICF.ITEM_ID]: string,
  [ICF.NAME]: string,
  [ICF.DESCRIPTION]: string|null,
}

export class Item {
  id: string|null = null
  name: string|null = null
  descriptionText: string|null = ""
  constructor(props:Partial<ItemProps>) {
    this[ICF.ID] = props[ICF.ID]
    this[ICF.NAME] = props[ICF.NAME]
    this[ICF.DESCRIPTION] = props[ICF.DESCRIPTION]
  }
  toJSON(): ItemProps {
    return {
      [ICF.ID]: this[ICF.ID],
      [ICF.NAME]: this[ICF.NAME],
      [ICF.DESCRIPTION]: this[ICF.DESCRIPTION],
    }
  }

  toFirestoreJSON(): Partial<ItemProps> {
    const {id, ...rest} = this.toJSON()
    for (const key in rest) {
      if (rest[key] === "" || rest[key] === null || rest[key] === undefined) {
        delete rest[key]
      }
    }
    return rest
  }

  static defaultItem():Item {
    return new Item({id: "", name: "", descriptionText: ""})
  }
}
------------------------------------------------------------------------------------

export function getCollectionReference<T>(collectionName:Collections, converter: (ref: CollectionReference<DocumentData>)=>CollectionReference<T>): CollectionReference<T> {
  return converter(firestoreAccessor(collectionName))
}

export function getDocumentReference<T>(collectionName:Collections, itemID:string, converter: (ref: DocumentReference<DocumentData>)=>DocumentReference<T>): DocumentReference<T> {
  return converter(doc(getFirestore(), collectionName, itemID))
}

export function collectionWithConverter(ref: CollectionReference<DocumentData>): CollectionReference<Item> {
  return ref.withConverter({
    toFirestore: (Item: Item) => {
      return Item.toFirestoreJSON()
    },
    fromFirestore: (snapshot: QueryDocumentSnapshot, options: SnapshotOptions) => {
      const data = snapshot.data(options) as ItemProps
      return new Item({...data, "id": snapshot.id, "itemID": snapshot.id})
    }})
}

export function documentWithConverter(ref: DocumentReference<DocumentData>): DocumentReference<Item> {
  return ref.withConverter({
    toFirestore: (Item: Item) => {
      return Item.toFirestoreJSON()
    },
    fromFirestore: (snapshot: QueryDocumentSnapshot, options: SnapshotOptions) => {
      const data = snapshot.data(options) as ItemProps
      return new Item({...data, "id": snapshot.id, "itemID": snapshot.id})
    }})
}
------------------------------------------------------------------------------------
#Example Use case
export async function getItem(itemID: string): Promise<Item|null> {
  const ref = getDocumentReference<Item>(Collections.ITEMS, itemID, documentWithConverter)
  const snap = await getDoc(ref)

  if (snap.exists) {
    return snap.data()
  }
  return null
}
2 Answers

It appears that it’s possible to write a generic FirestoreDataConverter object according to this guide. The custom object in the guide is written to take from any type, much like you are currently trying to do. I have tested specifically the generic data converter:

// Test class
class User{
    constructor(readonly fName: string, readonly lName: string){
        this.fName = fName;
        this.lName = lName;
    }

    toString(): string{
        return `First name: ${this.fName}, Last name: ${this.lName}`;
    }

}

//Generic converter
const genericConverter = <T>() => ({
    toFirestore(data: Partial<T>) {
        return data;
    },
    fromFirestore(snapshot: FirebaseFirestore.QueryDocumentSnapshot): T {
        return snapshot.data() as T;
    }
});

//Sample function to get document
async function getFireDocs(){
    const userSnap = await fireDB
        .collection('simpleUser')
        .withConverter(genericConverter<User>())
        .doc('simpleUser1').get();
    const testUser = new User(userSnap.data().firstName, 
userSnap.data().lastName);
    if (testUser !== undefined) {
        console.log(testUser.fName);
        console.log(testUser.lName);
        console.log(testUser.toString());
        console.log(testUser)
    }
}
getFireDocs();

Output:

John
Smith
First name: John, Last name: Smith
User { fName: 'John', lName: 'Smith' }

The complete code from this article is found here. It seems that the use of a Partial type helps in this case, since it allows properties to be optional of object models. Here is the full FirestoreDataConveter documentation with additional examples.

Example to convert a database type to a local type (in this example, adding the id from database to the local type):

interface DatabaseType {
  foo: boolean;
}
interface LocalType extends DatabaseType {
  id: string;
}

const converter: FirestoreDataConverter<LocalType> = {
    toFirestore: (item) => item,
    fromFirestore: (snapshot: QueryDocumentSnapshot<DatabaseType>, options) => {
      const data = snapshot.data(options);
      return {
        ...data,
        id: snapshot.id
      };
    }
};

const q = query(...).withConverter(converter);
Related