扁平化界面

Flatten an interface

我有一个用例,我希望能够根据泛型类型派生出我的类型的 keys/fields。

例如:

这些是容器接口

export interface IUser {
    id: BigInt;
    name: string;
    balance: number;
    address: Address;
}
export interface Address {
    street: string;
    city: string;
    zipcode: number;
    tags: string[];
}

我希望能够定义类似

的类型
const matcher: Matcher<IUser> = {
  id: 1
}

这是我可以使用如下实施的 Matcher 来完成的事情

export type Matcher<T> = {
    [K in keyof T]: (number | string | boolean)
}

但是现在对于像

这样的用例
const deepMatcher: Matcher<IUser> = {
  id: 1,
  user.address.city: 'San Francisco'
}

如何更新我的模型 (Matcher) 以支持此用例?

免责声明:这些类型的问题总是有很多边缘情况。此解决方案适用于您的示例,但当我们引入可选属性或联合等内容时可能会中断。此解决方案也不包括数组内对象的路径。

解决方案由两大部分组成:

首先我们需要一个类型,它将采用类型 T 并构造 T.

的所有路径的并集
type AllPaths<T, P extends string = ""> = {
    [K in keyof T]: T[K] extends object 
      ? T[K] extends any[] 
        ? `${P}${K & string}` 
        : AllPaths<T[K], `${P}${K & string}.`> extends infer O 
          ? `${O & string}` | `${P}${K & string}`
          : never 
        : `${P}${K & string}`
}[keyof T]

AllPaths是递归类型。每条路径的进度存储在 P.

它映射 T 的所有属性并进行一些检查。如果 T[K] 是一个数组或者不是一个对象,它只是将 K 附加到当前路径并 return 添加它。

T[K] extends object 
  ? T[K] extends any[] 
    ? `${P}${K & string}` 
    : /* ... */
  : `${P}${K & string}`

如果T[K]是一个对象,我们可以递归调用AllPaths,将T[K]作为新的T和当前路径,再次使用当前key。这次我们还将一个 "." 添加到路径中,因为我们知道稍后将添加一个嵌套的 属性。

AllPaths<T[K], `${P}${K & string}.`> extends infer O 
  ? `${O & string}` | `${P}${K & string}`
  : never 

这里的 infer O 是一种解决方法,因此 TypeScript 不会抱怨 Type instantiation is excessively deep and possibly infinite。我们 return 递归调用的结果和当前路径都在这里,所以我们可以将 addressaddress.street ...作为稍后的键。

让我们在这里查看结果:

type T0 = AllPaths<IUser>
// type T0 = "id" | "name" | "balance" | "address" | "address.street" | "address.city" | "address.zipcode" | "address.tags"

我们现在需要一个类型,它接受一个路径并为我们获取正确的路径类型。

type PathToType<T, P extends string> = P extends keyof T
  ? T[P]
  : P extends `${infer L}.${infer R}` 
    ? L extends keyof T
      ? PathToType<T[L], R>
      : never
    : never 

type T0 = PathToType<IUser, "address.street">
// type T0 = string

这也是递归类型。 P 这次从整条路径开始。

我们首先检查 P 是否是 keyof T。如果是,我们可以return T[P] 的类型。如果不是,我们尝试将 P 拆分为两个文字类型 LR ,它们必须用点分隔。如果左边的字符串文字匹配 T 的键,我们可以用 T[L] 和路径的其余部分递归调用 PathToType


最终我们在Matcher类型中使用了两种类型。

type Matcher<T> = Partial<{
    [K in AllPaths<T>]: PathToType<T, K & string>
}>

我们允许AllPaths<T>中的任何字符串作为键,并使用PathToType<T, K & string>获取相应类型的路径。

结果如下所示:

type T0 = Matcher<IUser>
// type T0 = {
//    id?: number | undefined;
//    name?: string | undefined;
//    balance?: number | undefined;
//    address?: Address | undefined;
//    "address.street"?: string | undefined;
//    "address.city"?: string | undefined;
//    "address.zipcode"?: number | undefined;
//    "address.tags"?: string[] | undefined;
//}

Playground