0

I'm trying to create a strictly typed API that only allows valid inputs. The API uses firestore internally, which admits any JSON document as a valid input, and I want to strictly limit what are valid inputs, both in the document path and the document structure. I don't want to create a gigantic file with all the types and have to update it every-time I add a new type that should be taken into account. I want to have the types and the valid document types collocated where they are used. For that reason, I'm trying to use module augmentation and extend an interface that says which values are acceptable and in which combinations. The objective is to associate certain object shapes with certain collection names and only admit valid values for those but also only valid combinations of them.

This is the generic wrapper around firebase that sets the basic stuff:

import { collection, DocumentReference, addDoc as addFirebaseDoc } from 'firebase/firestore';
import { getFirestoreDb } from '~/getFirestoreDb';

export interface DocsList {}

export async function addDoc<
  Type extends keyof DocsList,
  Collection extends DocsList[Type][0],
  Data extends DocsList[Type][1],
>(collectionName: Collection, doc: Data): Promise<DocumentReference> {
  const db = collection(getFirestoreDb(), collectionName);
  const result = await addFirebaseDoc(db, doc);
  return result;
}

When there was just one extension, it worked as expected:

// diary.ts file
declare module './database' {
  interface DocsList {
    readonly Diary: [Collections.diary, DiaryInput];
  }
}

addDoc('random-string', validDocument) // fails as expected
addDoc(Collectiobs.diary, invalidDocument) // fails as expected

Also, if I check the type of addDoc I can see it is correctly inferring the type of document and limiting to it as you can see on this screenshot: enter image description here

However, when I added to more extensions to the interface the inference is not correct. The problem is that instead of limiting the document type based on the provided collection it infers that the expected document is a sum type of all the possible valid documents, and that is not what I want.

For example, given the following extension:

declare module './database' {
  interface DocsList {
    readonly Log: [Collections.logs, LogEntryInput];
  }
}

This should not be permitted, but it is:

  addDoc(Collections.logs, diaryEntry); // should fail, but pass
  addDoc(Collections.diary, logEntry); // should fail, but pass

I also tried the following generics, to see if it is possible to limit it to the inferred value of type, but it does not work either:

export async function addDoc<
  Type extends keyof DocsList,
  Info extends DocsList[Type],
  Collection extends Info[0],
  Data extends Info[1],
>(collectionName: Collection, doc: Data): Promise<DocumentReference> {
  const db = collection(getFirestoreDb(), collectionName);
  const result = await addFirebaseDoc(db, doc);
  return result;
}

In case anyone wants to play with it, I created a little sandbox example:

https://codesandbox.io/s/module-augmentation-problem-1fuw2v?file=/src/index.ts

Danielo515
  • 5,996
  • 4
  • 32
  • 66

1 Answers1

0

I finally found a solution using function arguments rather than trying to use tuples or object properties. It seems that typescript understands better when you don't take the values out of the tuple at the type level, so apart from my solution there is another one a bit simpler but both will work.

So, here is the updated version of the addDoc function:

export interface DocsList {}

export enum Collections {
  Logs = "/logs",
  Diary = "/diary"
}

export async function addDoc<
  Type extends keyof DocsList,
  Args extends Parameters<DocsList[Type]>
>(...args: Args): Promise<void> {
  const [collectionName, doc] = args;
  console.log({ collectionName, doc });
}

And this is how I now declare the extensions, for example, below is the diary.ts file:

declare module "./database" {
  interface DocsList {
    Diary(Collection: Collections.Diary, Document: DiaryInput): unknown;
  }
}

Now the incorrect usages properly fails:

addDoc(Collections.Diary, { logType: "log" }); // should fail
addDoc(Collections.Logs, { author: "Bla" }); //  should fail

This is the error for the first incorrect call:

Argument of type '[Collections.Diary, { logType: "log"; }]' is not assignable to parameter of type '[Collection: Collections.Logs, Document: DolenciaInput] | [Collection: Collections.Diary, Document: DiaryInput] | [Collection: Collections.Logs, Document: LogEntryInput]'.
  Type '[Collections.Diary, { logType: "log"; }]' is not assignable to type '[Collection: Collections.Diary, Document: DiaryInput]'.
    Type at position 1 in source is not compatible with type at position 1 in target.
      Type '{ logType: "log"; }' is not assignable to type 'DiaryInput'.
        Object literal may only specify known properties, and 'logType' does not exist in type 'DiaryInput'.ts(2345)
Danielo515
  • 5,996
  • 4
  • 32
  • 66