typescript 递归获取对象子级返回类型脚本中的类型

tmb3ates  于 2023-05-01  发布在  TypeScript
关注(0)|答案(1)|浏览(136)

我正在使用firestore数据库,并创建了一个模式来建模我的数据:

type FirestoreCollection<T> = {
  documentType: T;
  subcollections?: {
    [key: string]: FirestoreCollection<object>;
  };
};

type FirestoreSchema<
  T extends { [key: string]: U },
  U = FirestoreCollection<object>
> = T;

export type FirestoreModel = FirestoreSchema<{
  'chat-messages': {
    documentType: ChatMessage;
  };
  chats: {
    documentType: Chat;
    subcollections: {
      'chat-participants': {
        documentType: ChatParticipant;
      };
    };
  };
}>;

这个模型运行良好。
然后我有一个方法可以引用任何集合或子集合,它正确地类型化了我的嵌套关系,看起来像这样:

type GetCollectionArgs<T = FirestoreModel> = {
  [K in keyof T]:
    | [{ collection: K; id: string }]
    | ('subcollections' extends keyof T[K]
        ? [
            { collection: K; id: string },
            ...GetCollectionArgs<T[K]['subcollections']>
          ]
        : never);
}[keyof T];

function getDocument(...args: GetCollectionArgs<FirestoreModel>) { ... }

然而,现在我想输入这个函数返回类型,以确保它将正确返回该集合的documentType

// should return 'ChatMessage'
getDocument({ collection: 'chat-messages', id: '123' });

// should return 'ChatParticipant'
getDocument(
  { collection: 'chats', id: '123' },
  { collection: 'chat-participants', id: '456' }
);

此外,我希望能够编写一个updateDocument()方法,以确保第一个参数中的数据是根据GetCollectionArgs的正确类型:

// included for brevity
type ChatMessage = { message: string }
type ChatParticipant = { name: string }

// this should work
updateDocument(
  { message: 'hello' },
  { collection: 'chat-messages', id: '123' }
)

// and this should too
updateDocument(
  { name: 'ryan' },
  { collection: 'chats', id: '123' },
  { collection: 'chat-participants', id: '456' }
)

// but this should error! 
updateDocument(
  { name: 'ryan' },
  { collection: 'chat-messages', id: '123' }
)

有什么想法吗

yqlxgs2m

yqlxgs2m1#

我采用的方法是创建一个对象类型的并集,这些对象类型对应于getDocument()的允许输入及其相关输出。也就是说,考虑到你的FirestoreModel

type FirestoreModel = {
    'chat-messages': {
        documentType: { message: string };
    };
    chats: {
        documentType: { chat: true };
        subcollections: {
            'chat-participants': {
                documentType: { name: string };
            };
        };
    };
};

我们将定义以下输入/输出的并集:

type DocIOFirestoreModel = {
    i: [{ collection: "chat-messages"; id: string; }];
    o: { message: string };
} | {
    i: [{ collection: "chats"; id: string; }];
    o: { chat: true };
} | {
    i: [{ collection: "chats"; id: string; }, 
        { collection: "chat-participants"; id: string; }];
    o: { name: string };
}

然后getDocument()可以有以下generic调用签名:

declare function getDocument<I extends DocIOFirestoreModel['i']>(
    ...input: I
): Extract<DocIOFirestoreModel, { i: I }>['o'];

其中,我们允许输入为DocIOFirestoreModeli属性之一,然后使用Extract实用程序类型选择相应的o属性。这将给予我们以下可取的行为:

const m = getDocument({ collection: 'chat-messages', id: '123' });
// const m: { message: string }

const p = getDocument(
    { collection: 'chats', id: '123' },
    { collection: 'chat-participants', id: '456' }
);
// const p: { name: string }

您可以通过将返回类型移动到参数类型来编写updateDocument()

declare function updateDocument<I extends DocIOFirestoreModel['i']>(
    val: Extract<DocIOFirestoreModel, { i: I }>['o'],
    ...input: I
): void;

这给出了以下期望的行为:

updateDocument(
    { message: 'hello' },
    { collection: 'chat-messages', id: '123' }
); // okay

updateDocument(
    { name: 'ryan' },
    { collection: 'chats', id: '123' },
    { collection: 'chat-participants', id: '456' }
); // okay

updateDocument(
    { name: 'ryan' }, // error, Argument of type '{ name: string; }' 
    // is not assignable to parameter of type '{ message: string; }'.
    { collection: 'chat-messages', id: '123' }
);

既然这一切都按照您希望的方式工作,我们现在的目标是定义一个实用程序类型函数,我们称之为DocIO<T>,它采用FireStoreCollection的某个子类型,定义为

type FireStoreCollection = {
    [k: string]: {
        documentType: any,
        subcollections?: FireStoreCollection
    }
}

并返回i/o类型的并集,因此我们不像上面那样手动写出DocIOFirestoreModel,而是写

type DocIOFirestoreModel = DocIO<FirestoreModel>;

它会为我们生成。
这里有一个方法:

type DocIO<T extends FireStoreCollection> = { [K in keyof T]:
    { i: [{ collection: K, id: string }], o: T[K]['documentType'] }
    | (T[K] extends { subcollections: infer F extends FireStoreCollection } ?
        DocIO<F> extends infer IOF ? IOF extends { i: infer I extends any[], o: infer O } ?
        { i: [{ collection: K, id: string }, ...I], o: O }
        : never : never : never)
}[keyof T]

这是一个递归定义的 * 分布式对象类型 *(ms/TS#47109中创造的术语),我们通过indexing into a mapped type得到该Map类型中属性的并集。
因此,对于每个键为K的属性,我们生成所需的类型作为两个部分的并集。第一个是非递归/基本块{i: [{collection: K, id: string}], o: T[K]['documentType']}。这意味着每个属性都有一个长度为1的输入,其中collection是该属性的名称,输出将是该属性的documentType。第二个是递归部分,上面写为三个嵌套的conditional types。看起来

T[K] extends { subcollections: infer F extends FireStoreCollection } ? // ➊ 
  DocIO<F> extends infer IOF ? // ➋
    IOF extends { i: infer I extends any[], o: infer O } ? // ➌
        { i: [{ collection: K, id: string }, ...I], o: O }
    : never 
  : never 
: never

第一个条件类型()将subcollections属性提取到新的类型参数F中。如果当前属性没有subcollections属性,它将变为never,递归到此停止。
第二个条件类型()通过计算DocIO<F>来执行实际的递归步骤;这被复制到一个新的类型参数IOF中,用于以下目的:
第三个条件类型()是一个分配条件类型,它分别对IOF的每个联合成员进行操作,然后将结果组合成一个新的联合。我想这样做,以便我们将子树的i/o类型彼此分开。它还将每个联合成员的io类型分别提取到新的类型参数IO中。
最后是实际返回的类型,o类型与O类型相同,但其i类型是当前路径,{collection: K, id: string}前置到I类型的旧i路径上。
这就是它,让我们来测试一下:

type DocIOFirestoreModel = DocIO<FirestoreModel>
/* type DocIOFirestoreModel = {
    i: [{ collection: "chat-messages"; id: string; }];
    o: { message: string; };
} | {
    i: [{ collection: "chats"; id: string; }];
    o: { chat: true; };
} | {
    i: [{ collection: "chats"; id: string; }, 
        { collection: "chat-participants"; id: string; }];
    o: { name: string; };
} */

看起来不错,这就是我们想要的!
Playground链接到代码

相关问题