Home > Mobile >  How to assign a value to a ref that has a generic type?
How to assign a value to a ref that has a generic type?

Time:12-13

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?

CodePudding user response:

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

  • Related