typescript:嵌套对象的深度keyof,具有相关类型

IT技术 javascript typescript mongodb mongodb-query typescript-generics
2021-03-15 07:16:45

我正在寻找一种方法来拥有嵌套对象的所有键/值对。

(用于 MongoDB 点符号键/值类型的自动完成)

interface IPerson {
    name: string;
    age: number;
    contact: {
        address: string;
        visitDate: Date;
    }
}

这是我想要实现的目标,使其成为:

type TPerson = {
    name: string;
    age: number;
    contact: { address: string; visitDate: Date; }
    "contact.address": string;
    "contact.visitDate": Date;
}

我尝试过的:

在这个答案中,我可以用Leaves<IPerson>. 所以就变成了'name' | 'age' | 'contact.address' | 'contact.visitDate'

@jcalz 的另一个答案中,我可以使用DeepIndex<IPerson, ...>.

是否可以将它们组合在一起,变成像这样的类型TPerson

修改 9/14:用例,需要和不需要:

当我开始提出这个问题时,我认为它可以像 一样简单[K in keyof T]: T[K];,并进行一些巧妙的转换。但是我错了。这是我需要的:

1. 索引签名

所以界面

interface IPerson {
    contact: {
        address: string;
        visitDate: Date;
    }[]
}

变成

type TPerson = {
    [x: `contact.${number}.address`]: string;
    [x: `contact.${number}.visitDate`]: Date;
    contact: {
        address: string;
        visitDate: Date;
    }[];
}

不需要检查 valid number,数组/索引签名的性质应该允许任意数量的元素。

2.元组

界面

interface IPerson {
    contact: [string, Date]
}

变成

type TPerson = {
    [x: `contact.0`]: string;
    [x: `contact.1`]: Date;
    contact: [string, Date];
}

元组应该是关心有效索引号的那个。

3. 只读

readonly 应从最终结构中删除属性。

interface IPerson {
    readonly _id: string;
    age: number;
    readonly _created_date: Date;
}

变成

type TPerson = {
    age: number;
}

用例是用于MongoDB的,则_id_created_date不能在数据已被创建后修改。_id: never在这种情况下不起作用,因为它会阻止TPerson.

4. 可选

interface IPerson {
    contact: {
        address: string;
        visitDate?: Date;
    }[];        
}

变成

type TPerson = {
    [x: `contact.${number}.address`]: string;
    [x: `contact.${number}.visitDate`]?: Date;
    contact: {
        address: string;
        visitDate?: Date;
    }[];
}

将可选标志带入转换结构就足够了。

5. 路口

interface IPerson {
    contact: { address: string; } & { visitDate: Date; }
}

变成

type TPerson = {
    [x: `contact.address`]: string;
    [x: `contact.visitDate`]?: Date;
    contact: { address: string; } & { visitDate: Date; }
}

6. 可以指定类型为异常

界面

interface IPerson {
    birth: Date;
}

变成

type TPerson = {
    birth: Date;
}

不是

type TPerson = {
    age: Date;
    "age.toDateString": () => string;
    "age.toTimeString": () => string;
    "age.toLocaleDateString": {
    ...
}

我们可以给出一个类型列表作为结束节点。

这是我不需要的:

  1. 联盟。它可能太复杂了。
  2. 类相关关键字。无需处理关键字 ex: private / abstract 。
  3. 其余的我没有在这里写。
2个回答

为了实现这个目标,我们需要创建所有允许路径的排列。例如:

type Structure = {
    user: {
        name: string,
        surname: string
    }
}

type BlackMagic<T>= T

// user.name | user.surname
type Result=BlackMagic<Structure>

数组和空元组的问题变得更有趣。

元组,显式长度的数组,应该这样管理:

type Structure = {
    user: {
        arr: [1, 2],
    }
}

type BlackMagic<T> = T

// "user.arr" | "user.arr.0" | "user.arr.1"
type Result = BlackMagic<Structure>

逻辑是直截了当的。但是我们怎么number[]办呢?不能保证索引1存在。

我决定使用user.arr.${number}.

type Structure = {
    user: {
        arr: number[],
    }
}

type BlackMagic<T> = T

// "user.arr" | `user.arr.${number}`
type Result = BlackMagic<Structure>

我们还有 1 个问题。空元组。具有零元素的数组 - []我们需要允许索引吗?我不知道。我决定使用-1.

type Structure = {
    user: {
        arr: [],
    }
}

type BlackMagic<T> = T

//  "user.arr" | "user.arr.-1"
type Result = BlackMagic<Structure>

我认为这里最重要的是一些约定。我们也可以使用字符串化的“从不”。我认为这取决于 OP 如何处理它。

既然我们知道我们需要如何处理不同的情况,我们就可以开始我们的实现了。在我们继续之前,我们需要定义几个助手。

type Values<T> = T[keyof T]
{
    // 1 | "John"
    type _ = Values<{ age: 1, name: 'John' }>
}

type IsNever<T> = [T] extends [never] ? true : false;
{
    type _ = IsNever<never> // true 
    type __ = IsNever<true> // false
}

type IsTuple<T> =
    (T extends Array<any> ?
        (T['length'] extends number
            ? (number extends T['length']
                ? false
                : true)
            : true)
        : false)
{
    type _ = IsTuple<[1, 2]> // true
    type __ = IsTuple<number[]> // false
    type ___ = IsTuple<{ length: 2 }> // false
}

type IsEmptyTuple<T extends Array<any>> = T['length'] extends 0 ? true : false
{
    type _ = IsEmptyTuple<[]> // true
    type __ = IsEmptyTuple<[1]> // false
    type ___ = IsEmptyTuple<number[]> // false

}

我认为命名和测试是不言自明的。至少我想相信:D

现在,当我们拥有所有的 utils 时,我们可以定义我们的主要 util:

/**
 * If Cache is empty return Prop without dot,
 * to avoid ".user"
 */
type HandleDot<
    Cache extends string,
    Prop extends string | number
    > =
    Cache extends ''
    ? `${Prop}`
    : `${Cache}.${Prop}`

/**
 * Simple iteration through object properties
 */
type HandleObject<Obj, Cache extends string> = {
    [Prop in keyof Obj]:
    // concat previous Cacha and Prop
    | HandleDot<Cache, Prop & string>
    // with next Cache and Prop
    | Path<Obj[Prop], HandleDot<Cache, Prop & string>>
}[keyof Obj]

type Path<Obj, Cache extends string = ''> =
    // if Obj is primitive
    (Obj extends PropertyKey
        // return Cache
        ? Cache
        // if Obj is Array (can be array, tuple, empty tuple)
        : (Obj extends Array<unknown>
            // and is tuple
            ? (IsTuple<Obj> extends true
                // and tuple is empty
                ? (IsEmptyTuple<Obj> extends true
                    // call recursively Path with `-1` as an allowed index
                    ? Path<PropertyKey, HandleDot<Cache, -1>>
                    // if tuple is not empty we can handle it as regular object
                    : HandleObject<Obj, Cache>)
                // if Obj is regular  array call Path with union of all elements
                : Path<Obj[number], HandleDot<Cache, number>>)
            // if Obj is neither Array nor Tuple nor Primitive - treat is as object    
            : HandleObject<Obj, Cache>)
    )

// "user" | "user.arr" | `user.arr.${number}`
type Test = Extract<Path<Structure>, string>

有一个小问题。我们不应该返回最高级别的props,例如user. 我们需要至少有一个点的路径。

有两种方式:

  • 提取所有没有点的props
  • 为索引级别提供额外的通用参数。

两个选项很容易实现。

获得所有propsdot (.)

type WithDot<T extends string> = T extends `${string}.${string}` ? T : never

虽然上面的 util 是可读和可维护的,但第二个有点难。我们需要在这两个提供额外的泛型参数PathHandleObject请参阅取自其他问题/文章的此示例

type KeysUnion<T, Cache extends string = '', Level extends any[] = []> =
  T extends PropertyKey ? Cache : {
    [P in keyof T]:
    P extends string
    ? Cache extends ''
    ? KeysUnion<T[P], `${P}`, [...Level, 1]>
    : Level['length'] extends 1 // if it is a higher level - proceed
    ? KeysUnion<T[P], `${Cache}.${P}`, [...Level, 1]>
    : Level['length'] extends 2 // stop on second level
    ? Cache | KeysUnion<T[P], `${Cache}`, [...Level, 1]>
    : never
    : never
  }[keyof T]

老实说,我认为任何人都不容易阅读此内容。

我们还需要实施一件事。我们需要通过计算路径获得一个值。


type Acc = Record<string, any>

type ReducerCallback<Accumulator extends Acc, El extends string> =
    El extends keyof Accumulator ? Accumulator[El] : Accumulator

type Reducer<
    Keys extends string,
    Accumulator extends Acc = {}
    > =
    // Key destructure
    Keys extends `${infer Prop}.${infer Rest}`
    // call Reducer with callback, just like in JS
    ? Reducer<Rest, ReducerCallback<Accumulator, Prop>>
    // this is the last part of path because no dot
    : Keys extends `${infer Last}`
    // call reducer with last part
    ? ReducerCallback<Accumulator, Last>
    : never

{
    type _ = Reducer<'user.arr', Structure> // []
    type __ = Reducer<'user', Structure> // { arr: [] }
}

您可以Reduce在我的博客中找到有关使用的更多信息

全码:

type Structure = {
    user: {
        tuple: [42],
        emptyTuple: [],
        array: { age: number }[]
    }
}


type Values<T> = T[keyof T]
{
    // 1 | "John"
    type _ = Values<{ age: 1, name: 'John' }>
}

type IsNever<T> = [T] extends [never] ? true : false;
{
    type _ = IsNever<never> // true 
    type __ = IsNever<true> // false
}

type IsTuple<T> =
    (T extends Array<any> ?
        (T['length'] extends number
            ? (number extends T['length']
                ? false
                : true)
            : true)
        : false)
{
    type _ = IsTuple<[1, 2]> // true
    type __ = IsTuple<number[]> // false
    type ___ = IsTuple<{ length: 2 }> // false
}

type IsEmptyTuple<T extends Array<any>> = T['length'] extends 0 ? true : false
{
    type _ = IsEmptyTuple<[]> // true
    type __ = IsEmptyTuple<[1]> // false
    type ___ = IsEmptyTuple<number[]> // false
}

/**
 * If Cache is empty return Prop without dot,
 * to avoid ".user"
 */
type HandleDot<
    Cache extends string,
    Prop extends string | number
    > =
    Cache extends ''
    ? `${Prop}`
    : `${Cache}.${Prop}`

/**
 * Simple iteration through object properties
 */
type HandleObject<Obj, Cache extends string> = {
    [Prop in keyof Obj]:
    // concat previous Cacha and Prop
    | HandleDot<Cache, Prop & string>
    // with next Cache and Prop
    | Path<Obj[Prop], HandleDot<Cache, Prop & string>>
}[keyof Obj]

type Path<Obj, Cache extends string = ''> =
    (Obj extends PropertyKey
        // return Cache
        ? Cache
        // if Obj is Array (can be array, tuple, empty tuple)
        : (Obj extends Array<unknown>
            // and is tuple
            ? (IsTuple<Obj> extends true
                // and tuple is empty
                ? (IsEmptyTuple<Obj> extends true
                    // call recursively Path with `-1` as an allowed index
                    ? Path<PropertyKey, HandleDot<Cache, -1>>
                    // if tuple is not empty we can handle it as regular object
                    : HandleObject<Obj, Cache>)
                // if Obj is regular  array call Path with union of all elements
                : Path<Obj[number], HandleDot<Cache, number>>)
            // if Obj is neither Array nor Tuple nor Primitive - treat is as object    
            : HandleObject<Obj, Cache>)
    )

type WithDot<T extends string> = T extends `${string}.${string}` ? T : never


// "user" | "user.arr" | `user.arr.${number}`
type Test = WithDot<Extract<Path<Structure>, string>>



type Acc = Record<string, any>

type ReducerCallback<Accumulator extends Acc, El extends string> =
    El extends keyof Accumulator ? Accumulator[El] : El extends '-1' ? never : Accumulator

type Reducer<
    Keys extends string,
    Accumulator extends Acc = {}
    > =
    // Key destructure
    Keys extends `${infer Prop}.${infer Rest}`
    // call Reducer with callback, just like in JS
    ? Reducer<Rest, ReducerCallback<Accumulator, Prop>>
    // this is the last part of path because no dot
    : Keys extends `${infer Last}`
    // call reducer with last part
    ? ReducerCallback<Accumulator, Last>
    : never

{
    type _ = Reducer<'user.arr', Structure> // []
    type __ = Reducer<'user', Structure> // { arr: [] }
}

type BlackMagic<T> = T & {
    [Prop in WithDot<Extract<Path<T>, string>>]: Reducer<Prop, T>
}

type Result = BlackMagic<Structure>

操场

这个实现值得考虑

@captain-yossarian 这绝对是美妙的。我注意到最后的 BlackMagic 类型有一个错字,它被硬编码为 Structure :)
2021-05-07 07:16:45
你说得对,我应该列出我需要的用例,否则那里可能有太多的边缘情况。数组/索引签名的句柄由$number足够好。我在问题中添加了一个用例列表,请查看它=)感谢您的回答和耐心的解释,我会在下班后阅读它(它的复杂性让我头疼,oop)
2021-05-08 07:16:45
我尝试了操场,我发现代码中有一些不起作用的警告/边缘情况。例如,如果我将“bar.baz”更改为数组类型(以及 bar.gaz.zaz),它将不起作用。而对于ExludeHighLevelfrom KeysUnionStructure &right after 对我来说看起来不太好(并且可能会导致更多的例外)。有机会改进吗?
2021-05-11 07:16:45
感谢提供另一种(也很棒)的方式。我用它做了一些测试,发现它无法检测数组类型。可以使用它吗?
2021-05-20 07:16:45
我不确定你想如何处理数组。假设您有一个空数组。你想让我生成:baz.0关于{ zaz: 2 }[]. I 和 typescript 都无法弄清楚哪些索引是允许的,哪些是不允许的。如果是数组,我应该生成多少个索引?另一方面,使用元组要容易得多,因为 TS 能够计算出长度
2021-05-21 07:16:45

下面是充分执行我有Flatten<T, O>其将一个类型可能嵌套T成“扁平”的键是虚线版本的路径通过原TO类型是一个可选类型,您可以在其中指定一个(联合)对象类型以保持原样而不将它们展平。在您的示例中,这只是Date,但您可以有其他类型。

警告:它非常丑陋,而且可能很脆弱。到处都有边缘情况。构成它的部分涉及奇怪的类型操作,这些操作要么并不总是按照人们的预期进行,要么对于除最经验丰富的 TypeScript 老手之外的所有人来说都是不可理解的,或者两者兼而有之。

有鉴于此,除了可能“请不要这样做”之外,对这个问题没有“规范”的答案。但我很高兴展示我的版本。

这里是:


type Flatten<T, O = never> = Writable<Cleanup<T>, O> extends infer U ?
    U extends O ? U : U extends object ?
    ValueOf<{ [K in keyof U]-?: (x: PrefixKeys<Flatten<U[K], O>, K, O>) => void }>
    | ((x: U) => void) extends (x: infer I) => void ?
    { [K in keyof I]: I[K] } : never : U : never;

这里的基本方法是采用您的T类型,如果它不是对象或它扩展,则按原样返回它O否则,我们将删除任何readonly属性,并将任何数组或元组转换为没有所有数组方法(如push()map())和 get 的版本U然后我们将其中的每个属性展平。我们有一把钥匙K和一个扁平的财产Flatten<U[K]>我们想在K中的虚线路径之前添加Flatten<U[K]>,当我们完成所有操作后,我们希望将这些扁平化对象(也包括未扁平化对象)相交成为一个大对象。

请注意,说服编译器产生交集涉及逆变位置中的条件类型推断(请参阅将联合类型转换为交集类型),这就是这些(x: XXX) => void)extends (x: infer I) => void片段进来的地方。它使编译器采用所有不同的XXX值并将它们相交以获得I

虽然像这样的交集{foo: string} & {bar: number} & {baz: boolean}在概念上是我们想要的,但它比等价的更丑,{foo: string; bar: number; baz: boolean}所以我做了一些更多的条件类型映射,{ [K in keyof I]: I[K] }而不仅仅是I(请参阅如何查看 Typescript 类型的完整扩展合同?)。

此代码通常分布在 unions 上,因此可选属性最终{a?: {b: string}}可能会产生unions(例如可能会产生{"a.b": string; a?: {b: string}} | {"a": undefined, a?: {b: string}},虽然这可能不是您想要的表示形式,但它应该可以工作(因为,例如,"a.b"如果a,则可能不作为键存在)选修的)。


Flatten定义取决于辅助型的功能,我将在这里提出各种层次的描述:

type Writable<T, O> = T extends O ? T : {
    [P in keyof T as IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P>]: T[P]
}

type IfEquals<X, Y, A = X, B = never> =
    (<T>() => T extends X ? 1 : 2) extends
    (<T>() => T extends Y ? 1 : 2) ? A : B;

Writable<T, O>一个版本的回报Treadonly性能删除(除非T extends O在这种情况下,我们不要管它)。它来自TypeScript 条件类型——过滤掉只读属性/只选择需要的属性

下一个:

type Cleanup<T> =
    0 extends (1 & T) ? unknown :
    T extends readonly any[] ?
    (Exclude<keyof T, keyof any[]> extends never ?
        { [k: `${number}`]: T[number] } : Omit<T, keyof any[]>) : T;

Cleanup<T>类型变成any类型所述unknown类型(因为any真犯规上型操纵),圈元组与只是个别numericlike键(对象"0""1"等),和其它匝阵列整合到只是一个单一的索引签名

下一个:

type PrefixKeys<V, K extends PropertyKey, O> =
    V extends O ? { [P in K]: V } : V extends object ?
    { [P in keyof V as
        `${Extract<K, string | number>}.${Extract<P, string | number>}`]: V[P] } :
    { [P in K]: V };

PrefixKeys<V, K, O>将键添加KV的属性键中的路径...除非V扩展OV不是对象。它使用模板文字类型来做到这一点。

最后:

type ValueOf<T> = T[keyof T]

将类型T转换为其属性的联合。请参见TypeScript 中是否有类似于 `keyof` 的 `valueof`?.

哇!😅


所以,你去了。您可以验证这与您陈述的用例的紧密程度。但它非常复杂和脆弱,我真的不建议在没有大量测试的任何生产代码环境中使用它

Playground 链接到代码

优秀的答案!它对我来说已经足够优雅了。谢谢!
2021-05-12 07:16:45