How to assign a value to a ref that has a generic type?

Viewed 448

I am writing a Vue composable using TypeScript.

It takes in a generic type T and a single paramter path, and returns a document ref.

I've almost got it working but whenever I try to assign a value to the document ref it throws an error like this:

Type '{ id: string; }' is not assignable to type 'T'.
  'T' could be instantiated with an arbitrary type which could be unrelated to '{ id: string; }'.ts(2322)

Here is a trimmed down version of the composable:

import { ref, Ref } from "vue";
import { projectFirestore } from "@/firebase/config";
import { doc, onSnapshot } from "firebase/firestore";

const getDocument = <T>(
  path: string
): {
  document: Ref<T | null>;
} => {
  const document = ref(null) as Ref<T | null>;
  const docRef = doc(projectFirestore, path);
  onSnapshot(docRef, (doc) => {
    document.value = { //<-- Error here on "document.value"
      ...doc.data(),
      id: doc.id,
    };
  });
  return { document };
};

export default getDocument;

It doesn't matter what I assign to document.value (strings, an empty object, etc) it always gives a similar error saying it is not assignable to type T.

I understand the error is telling me the type T could be anything, and therefore it's not safe to assign these things because the type of the things might be not be compatible with type T.

But how do I solve this problem? Can I somehow tell TypeScript that type T is compatible with other types?

3 Answers

T is only used in your return type. Your generic is declaring that the resulting Ref is the caller's choice of T with no boundaries.

Even if you could specify that T extends { id: string } or that your return type is T & { id: string }—and you can, using that syntax—you'd have to defeat strong typing at some point, because at runtime getDocument won't ever know enough about T to assert that doc.data() will be compatible with the particular T that the getDocument call infers.

I'd pick one of these three options:

  • Remove the generic and return Ref<{id: string} | null>, which is type-safe but would force you to use ref["key"] notation to check arbitrary unknown keys.
  • Keep the generic and trust your calls to getDocument to specify the right type, figuring that it's much more likely for your code to introduce bugs than for Firestore to give you an unexpected data type. You'd need to use as any to show TypeScript you're deliberately dropping type safety in assigning document.value.
  • Use a type predicate that you pass into getDocument along with the path. The predicate is a function that would take a single arg and return whether its arg is T, which you could check in onSnapshot to ensure that your document is safe to cast to T or fail with the mitigation/error of your design. This also constrains T so your generic can't be absolutely any type. See example below.

const getDocument = <T>(
  path: string,
  docPredicate: (doc: any) => doc is T,  // accept predicate
): {
  document: Ref<T & {id: string} | null>;
} => {
  const document = ref(null) as Ref<T & {id: string} | null>;
  const docRef = doc(projectFirestore, path);
  onSnapshot(docRef, (doc) => {
    const data = doc.data(); // extract to local variable
    if(docPredicate(data)) { // convince TS data is type T
      document.value = {
        ...data,             // integrate as expected
        id: doc.id,
      };
    }
  });
  return { document };
};

interface Foo { a: string, b: number }
function isFoo(doc: any): doc is Foo {
  // TODO: check that doc has {a: string, b: number}
  return true;
}

const fooDoc = getDocument("bar", isFoo);
if (fooDoc.document.value) {
  console.log(fooDoc.document.value.a);
  console.log(fooDoc.document.value.id);
}

Playground before => Playground after

I just discovered another solution.

Since Firebase provides a DocumentData interface, I used that in the document ref and return type.

Doing so allowed me to remove the T generic entirely.

This makes it a much simpler composable function without the need to create and pass in my own interfaces. It resolves the errors in this composable, and the errors in the Vue SFC template when called.

Here is the full code:

import { ref, Ref } from "vue";
import { projectFirestore } from "@/firebase/config";
import {
  doc,
  onSnapshot,
  DocumentSnapshot,
  DocumentData,
} from "firebase/firestore";

const getDocument = (
  path: string
): {
  document: Ref<DocumentData | null>;
} => {
  const document = ref<DocumentData | null>(null);
  const docRef = doc(projectFirestore, path);
  onSnapshot(docRef, (doc: DocumentSnapshot<DocumentData>) => {
    if (doc.data()) {
      document.value = {
        ...doc.data(),
        id: doc.id,
      };
    }
  });
  return { document };
};

export default getDocument;

enter image description here Or you can just use my library, https://firelordjs.com/quick_start/

the library has a very deep type system, can handle all types in all operations regardless of how deeply nested your data type is

it is virtually impossible to make typing mistakes with it

currently only the web version is available, the admin version probably arrive in 2 or 3 months (it is not easy to make this thing)

support @firebase/rules-unit-testing

Related