Skip to content

Make UpdateData type Generic to fix lack of type safety when using withConverter() #1448

@JamieCurnow

Description

@JamieCurnow

Is your feature request related to a problem? Please describe.
This package works great with Typescript when using the .withConverter method and enforces correct types when performing a get() or set() or onSnapshot(), but fails to enforce types on the update() method.

With TS v4.1 we can now safely enforce that dot-notation strings as object keys are correct, so we can do better than:

export type UpdateData = {[fieldPath: string]: any};

Check it out...

import { firestore } from "firebase-admin"

interface User {
  name: string
  email: string
  address: {
    line1: string
    line2: string
    postcode: string
    verified: boolean
    timeAtAddress: {
      days: string
      months: string
      hours: string
    }
  }
}

// This helper function pipes my types through the converter
const converter = <T>() => ({
  toFirestore: (data: Partial<T>) => data,
  fromFirestore: (snap: FirebaseFirestore.QueryDocumentSnapshot) => snap.data() as T
})

const getUser = async () => {
  const userDoc = await firestore().collection('user').withConverter(converter<User>()).doc('1234').get()
  if (!userDoc.exists) return null
  const userData = userDoc.data()
  console.log(userData.email) // nice!
  console.log(userData.unknownKey)
  // TS Error: Property 'unknownKey' does not exist on type 'User'.
  // Great!
}

const setUser = async () => {
  await firestore().collection('user').withConverter(converter<User>()).doc('1234').set({
    email: '',
    name: ''
  })
  // TS Error: Property 'address' is missing in type '{ email: string; name: string; }' but required in type 'User'
  // Great!
}

const setUserMerged = async () => {
  await firestore().collection('user').withConverter(converter<User>()).doc('1234').set({
    email: '',
    name: '',
    unknownKey: ''
  }, { merge: true })
  // TS Error: Object literal may only specify known properties, and 'unknownKey' does not exist in type 'Partial<User>'.
  // Great!
}

const updateUserUnsafe = async () => {
  await firestore().collection('user').withConverter(converter<User>()).doc('1234').update({
    unknownKey: '', // no error here
    'anything.I.like': true // no type error
  })
}

// Here's how we could do an update with type safety:

type PathImpl<T, K extends keyof T> =
  K extends string
  ? T[K] extends Record<string, any>
  ? T[K] extends ArrayLike<any>
  ? K | `${K}.${PathImpl<T[K], Exclude<keyof T[K], keyof any[]>>}`
  : K | `${K}.${PathImpl<T[K], keyof T[K]>}`
  : K
  : never

type Path<T> = PathImpl<T, keyof T> | keyof T

type PathValue<T, P extends Path<T>> =
  P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
  ? Rest extends Path<T[K]>
  ? PathValue<T[K], Rest>
  : never
  : never
  : P extends keyof T
  ? T[P]
  : never

type CustomUpdateData<T extends object> = Partial<{
  [TKey in Path<T>]: PathValue<T, TKey>
}>

const updateUserSafe = async () => {
  // Here I make the object first which provides some safety
  const updatesObject: CustomUpdateData<User> = {
    name: '',
    'address.timeAtAddress.days': '',
    'some.unknown.path': false,
    // Object literal may only specify known properties, and ''some.unknown.path'' does not exist in type 'Partial<CustomUpdateData<User>>'
    'address.verified': 'Oops! Wrong value type!'
    // Type 'string' is not assignable to type 'boolean'
  }

  await firestore().collection('user').withConverter(converter<User>()).doc('1234').update(updatesObject)
}

Describe the solution you'd like
I propose that the helper types for creating a type that is able to handle object paths should be added to FirebaseFirestore.UpdateData. I think that a solution could look like this:

// @google-cloud/firestore/types/firestore.d.ts

type PathImpl<T, K extends keyof T> =
  K extends string
  ? T[K] extends Record<string, any>
  ? T[K] extends ArrayLike<any>
  ? K | `${K}.${PathImpl<T[K], Exclude<keyof T[K], keyof any[]>>}`
  : K | `${K}.${PathImpl<T[K], keyof T[K]>}`
  : K
  : never

type Path<T> = PathImpl<T, keyof T> | keyof T

type PathValue<T, P extends Path<T>> =
  P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
  ? Rest extends Path<T[K]>
  ? PathValue<T[K], Rest>
  : never
  : never
  : P extends keyof T
  ? T[P]
  : never
  
declare namespace FirebaseFirestore {
  export type DocumentData = { [field: string]: any }

  export type UpdateData<T = DocumentData> = Partial<{
    [TKey in Path<T>]: PathValue<T, TKey>
  }>

  // ...
  
  export class DocumentReference<T = DocumentData> {
    // ...
    update(data: UpdateData<T>, precondition?: Precondition): Promise<WriteResult>
  }
}

Which would allow type safety like this:

const updateSafe = async () => {
  await firestore().collection('user').withConverter(converter<User>()).doc('1234').update({
    name: '',
    'address.line1': '',
    'some.unknown.path': false,
    // Object literal may only specify known properties, and ''some.unknown.path'' does not exist in type 'Partial<CustomUpdateData<User>>'
    'address.verified': 'Oops! Wrong value type!'
    // Type 'string' is not assignable to type 'boolean'
  })
}

Additional context
Proposed solution in action:
Screenshot 2021-03-12 at 12 42 17

Metadata

Metadata

Assignees

Labels

api: firestoreIssues related to the googleapis/nodejs-firestore API.type: feature request‘Nice-to-have’ improvement, new feature or different behavior or design.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions