【问题标题】:Typescript: deep keyof of a nested object, with related type打字稿:嵌套对象的深度键,具有相关类型
【发布时间】:2021-11-06 15:11:31
【问题描述】:

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

(用于 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;
}

我尝试过的:

在这个answer,我可以用Leaves<IPerson>得到钥匙。 所以它变成了'name' | 'age' | 'contact.address' | 'contact.visitDate'

在来自@jcalz 的另一个answer 中,我可以使用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;
    }[];
}

无需检查有效的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. 类相关关键字。无需处理关键字,例如:私有/抽象。
  3. 其他的我这里就不写了。

【问题讨论】:

  • 这些“扁平子属性”问题似乎从来没有充分考虑边缘情况,它们让我陷入疯狂。您会根据什么原则决定不在这里深入了解Date?见this code。你想在这里改变什么?我怎么知道 Date 应该只是 Date 而其他对象类型应该被展平?
  • 我想我可以将Date 硬编码为一个例外,例如this,但这是非常临时的。
  • 你关心数组吗?喜欢this?有很多警告。
  • 我的意思是,有很多潜在的边缘情况:索引签名、只读属性、可选属性、模式模板文字键......我很高兴写下我所拥有的,但是失败模式这东西可以装一本书。理想情况下,您会告诉我您需要支持的类型;听起来数组很重要(具有索引签名)......还有什么?
  • @jcalz 我用用例列表修改了我的问题。您的示例代码比其他答案对我帮助很大。在我仔细考虑之前,我几乎认为这已经是一个完美的答案了。我现在忙于日常生活,你让我回到轨道上问一个好问题=)

标签: javascript typescript mongodb mongodb-query typescript-generics


【解决方案1】:

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

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>

我认为这里最重要的是一些约定。我们也可以使用字符串化的“never”。我认为这取决于 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

现在,当我们拥有所有工具集后,我们可以定义我们的主要工具:

/**
 * 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>

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

有两种方式:

  • 提取所有不带点的道具
  • 为索引级别提供额外的通用参数。

两个选项很容易实现。

使用dot (.)获取所有道具:

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

虽然上面的 util 是可读和可维护的,但第二个有点难。我们需要在PathHandleObject 中提供额外的通用参数。 看这个例子取自其他question/article

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: [] }
}

您可以找到更多关于使用Reducein my blog的信息。

完整代码:

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>

Playground

This 的实现值得考虑

【讨论】:

  • 感谢您提供另一种(同样出色的)方式。我用它做了一些测试,发现它无法检测到数组类型。是否可以使用它?
  • 我尝试了操场,在代码中发现了一些不起作用的警告/边缘情况。例如,如果我将“bar.baz”更改为数组类型(以及 bar.gaz.zaz),它将不起作用。然后从KeysUnionExludeHighLevelStructure &amp; 对我来说看起来不太好(并且可能导致更多例外)。有没有改进的机会?
  • 我不确定你想如何处理数组。假设您有一个空数组。你想让我生成:baz.0?关于{ zaz: 2 }[]。我和 typescript 都无法弄清楚哪些索引是允许的,哪些是不允许的。如果是数组,我应该生成多少个索引?另一方面,使用元组更容易,因为 TS 能够计算出长度
  • 你说得对,我应该列出我需要的用例,否则那里可能有太多的边缘用例。 $number 的数组/索引签名的句柄已经足够好了。我在问题中添加了一个用例列表,请看一下=)感谢您的回答和耐心的解释,我会在下班后阅读它(它的复杂性让我大吃一惊,哎呀)
  • @captain-yossarian 这真是太棒了。我注意到最后的 BlackMagic 类型有一个错字,它被硬编码为 Structure :)
【解决方案2】:

下面是我对Flatten&lt;T, O&gt; 的完整实现,它将可能嵌套的T 类型转换为“扁平”版本,其键是通过原始T 的虚线路径O 类型是一个可选类型,您可以在其中指定一个(联合)对象类型以保持原样而不展平它们。在您的示例中,这只是 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())并获得U。然后我们将其中的每个属性展平。我们有一个密钥K 和一个扁平属性Flatten&lt;U[K]&gt;;我们想在Flatten&lt;U[K]&gt; 中的虚线路径前添加键K,当我们完成所有我们想要intersect 的操作时,这些扁平对象(也包括未扁平对象)一起成为一个大对象.

请注意,说服编译器产生交集涉及逆变位置的条件类型推断(请参阅Transform union type to intersection type),这就是(x: XXX) =&gt; void)extends (x: infer I) =&gt; void 部分的用武之地。它使编译器采用所有不同的@ 987654351@ 值并将它们相交得到I

虽然像{foo: string} &amp; {bar: number} &amp; {baz: boolean} 这样的交集是我们在概念上想要的,但它比等价的{foo: string; bar: number; baz: boolean} 更丑陋,所以我用{ [K in keyof I]: I[K] } 进行了一些条件类型映射,而不仅仅是I(参见How can I see the full expanded contract of a Typescript type?)。

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


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&lt;T, O&gt; 返回 T 的版本,其中删除了 readonly 属性(除非 T extends O 在这种情况下我们不理会它)。来自TypeScript conditional types - filter out readonly properties / pick only required properties

下一步:

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&lt;T&gt; 类型将the any type 转换为the unknown type(因为any 确实破坏了类型操作),将tuples 转换为仅具有单个数字键的对象("0""1" 等) , 并将其他数组转换为单个 index signature

下一步:

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&lt;V, K, O&gt;K 键添加到 V 的属性键中的路径...除非 V 扩展 OV 不是对象。它使用template literal types 来执行此操作。

最后:

type ValueOf<T> = T[keyof T]

将类型T 转换为其属性的联合。见Is there a `valueof` similar to `keyof` in TypeScript?

哇! ?


那么,就这样吧。您可以验证这与您声明的用例有多接近。但它非常复杂和脆弱,如果没有大量测试,我真的不建议在任何生产代码环境中使用它。

Playground link to code

【讨论】:

  • 优秀的答案!它对我来说已经足够优雅了。谢谢!
猜你喜欢
  • 2020-02-14
  • 2021-06-28
  • 2020-11-30
  • 2021-12-06
  • 2020-11-16
  • 2020-04-19
  • 1970-01-01
相关资源
最近更新 更多