打字稿:嵌套对象的深度键

Posted

技术标签:

【中文标题】打字稿:嵌套对象的深度键【英文标题】:Typescript: deep keyof of a nested object 【发布时间】:2020-02-14 11:29:52 【问题描述】:

所以我想找到一种方法来拥有嵌套对象的所有键。

我有一个泛型类型,它采用参数类型。我的目标是获取给定类型的所有键。

以下代码在这种情况下运行良好。但是当我开始使用嵌套对象时,情况就不同了。

type SimpleObjectType = 
  a: string;
  b: string;
;

// works well for a simple object
type MyGenericType<T extends object> = 
  keys: Array<keyof T>;
;

const test: MyGenericType<SimpleObjectType> = 
  keys: ['a'];

这是我想要实现的,但它不起作用。

type NestedObjectType = 
  a: string;
  b: string;
  nest: 
    c: string;
  ;
  otherNest: 
    c: string;
  ;
;

type MyGenericType<T extends object> = 
  keys: Array<keyof T>;
;

// won't works => Type 'string' is not assignable to type 'a' | 'b' | 'nest' | 'otherNest'
const test: MyGenericType<NestedObjectType> = 
  keys: ['a', 'nest.c'];

那么在不使用函数的情况下,我该怎么做才能将这种密钥提供给test

【问题讨论】:

没有办法像你想“nest.c”那样连接字符串字面量,但如果这足够的话,你可以在你的键中包含“c” @JurajKocan 好吧,如您所见,c 存在于nestotherNest 中。所以我认为这还不够。你的解决方案是什么? 您可以将路径表示为像keys: [["a"], ["nest", "c"]] 这样的元组(这里我可能会说paths 而不是keys)。如果Paths&lt;NestedObjectType&gt;[] | ["a"] | ["b"] | ["nest"] | ["nest", "c"] | ["otherNest"] | ["otherNest", "c"](或者如果您只关心叶节点,则可能是["a"] | ["b"] | ["nest", "c"] | ["otherNest", "c"]),那行得通吗?即使是这样,这也有点棘手,因为 Paths 的明显实现是以不完全支持的方式递归的。另外,您希望 Paths&lt;Tree&gt;type Tree = l: Tree, r: Tree 是什么? @jcalz 我认为它会完美地工作。你知道是否已经有实现Paths 的本机内置或库? 可能相关:***.com/questions/58361316/… 【参考方案1】:

TS4.1 更新现在可以使用microsoft/TypeScript#40336 中实现的模板文字类型,在类型级别连接字符串文字。可以调整以下实现以使用它而不是 Cons 之类的东西(它本身可以使用 variadic tuple types 作为 introduced in TypeScript 4.0 来实现):

type Join<K, P> = K extends string | number ?
    P extends string | number ?
    `$K$"" extends P ? "" : "."$P`
    : never : never;

这里Join 连接两个字符串,中间有一个点,除非最后一个字符串为空。所以Join&lt;"a","b.c"&gt;"a.b.c"Join&lt;"a",""&gt;"a"

那么PathsLeaves就变成了:

type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
     [K in keyof T]-?: K extends string | number ?
        `$K` | Join<K, Paths<T[K], Prev[D]>>
        : never
    [keyof T] : ""

type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
     [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> [keyof T] : "";

而其他类型则不在其中:

type NestedObjectPaths = Paths<NestedObjectType>;
// type NestedObjectPaths = "a" | "b" | "nest" | "otherNest" | "nest.c" | "otherNest.c"
type NestedObjectLeaves = Leaves<NestedObjectType>
// type NestedObjectLeaves = "a" | "b" | "nest.c" | "otherNest.c"

type MyGenericType<T extends object> = 
    keys: Array<Paths<T>>;
;

const test: MyGenericType<NestedObjectType> = 
    keys: ["a", "nest.c"]

其余的答案基本相同。递归条件类型(在microsoft/TypeScript#40002 中实现)也将在 TS4.1 中得到支持,但递归限制仍然适用,因此您会遇到没有像 Prev 这样的深度限制器的树状结构的问题。

请注意,这将使非圆点键生成圆点路径,例如foo: ["bar-baz": 1] 可能会产生foo.0.bar-baz。所以要小心避免这样的键,或者重写上面的以排除它们。

还请注意:这些递归类型本质上是“棘手的”,如果稍作修改,往往会使编译器不满意。如果你不走运,你会看到诸如“类型实例化过深”之类的错误,如果你非常不走运,你会看到编译器耗尽了你所有的 CPU 并且永远不会完成类型检查。对于这类问题,我不知道该说些什么……只是这样的事情有时比它们的价值更麻烦。

Playground link to code



PRE-TS4.1 答案:

如前所述,目前无法在类型级别连接字符串文字。有一些建议可能会允许这样做,例如 a suggestion to allow augmenting keys during mapped types 和 a suggestion to validate string literals via regular expression,但目前这是不可能的。

您可以将它们表示为字符串文字的tuples,而不是将路径表示为虚线字符串。所以"a"变成["a"]"nest.c"变成["nest", "c"]。在运行时,很容易通过split()join() 方法在这些类型之间进行转换。


所以你可能想要像Paths&lt;T&gt; 这样的东西,它返回给定类型T 的所有路径的联合,或者可能是Leaves&lt;T&gt;,它只是Paths&lt;T&gt; 中指向非对象类型本身的那些元素.没有对这种类型的内置支持; ts-toolbelt 库 has this,但由于我无法在 Playground 中使用该库,所以我将在此处推出自己的库。

请注意:PathsLeaves 本质上是递归的,这对编译器来说可能非常繁重。 recursive types of the sort needed for this 在 TypeScript 中也是 not officially supported。我将在下面展示的是以这种不确定/不真正支持的方式递归的,但我尝试为您提供一种指定最大递归深度的方法。

我们开始吧:

type Cons<H, T> = T extends readonly any[] ?
    ((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never
    : never;

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]

type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
     [K in keyof T]-?: [K] | (Paths<T[K], Prev[D]> extends infer P ?
        P extends [] ? never : Cons<K, P> : never
    ) [keyof T]
    : [];


type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
     [K in keyof T]-?: Cons<K, Leaves<T[K], Prev[D]>> [keyof T]
    : [];

Cons&lt;H, T&gt; 的意图是采用任何类型 H 和元组类型 T 并生成一个带有 H 的新元组在 T 前。所以Cons&lt;1, [2,3,4]&gt; 应该是[1,2,3,4]。该实现使用rest/spread tuples。我们需要它来构建路径。

Prev 类型是一个长元组,您可以使用它来获取前一个数字(最大为最大值)。所以Prev[10]9Prev[1]0。当我们深入对象树时,我们需要它来限制递归。

最后,Paths&lt;T, D&gt;Leaves&lt;T, D&gt; 是通过进入每个对象类型 T 并收集键,然后 Cons 将它们添加到这些键的属性的 PathsLeaves 上来实现的。它们之间的区别在于Paths 还直接包含联合中的子路径。默认情况下,深度参数D10,并且每向下一步我们将D 减一,直到我们尝试超过0,此时我们停止递归。


好的,我们来测试一下:

type NestedObjectPaths = Paths<NestedObjectType>;
// type NestedObjectPaths = [] | ["a"] | ["b"] | ["c"] | 
// ["nest"] | ["nest", "c"] | ["otherNest"] | ["otherNest", "c"]
type NestedObjectLeaves = Leaves<NestedObjectType>
// type NestedObjectLeaves = ["a"] | ["b"] | ["nest", "c"] | ["otherNest", "c"]

要查看深度限制的用处,假设我们有这样的树类型:

interface Tree 
    left: Tree,
    right: Tree,
    data: string

嗯,Leaves&lt;Tree&gt; 是,呃,很大:

type TreeLeaves = Leaves<Tree>; // sorry, compiler ?⌛?
// type TreeLeaves = ["data"] | ["left", "data"] | ["right", "data"] | 
// ["left", "left", "data"] | ["left", "right", "data"] | 
// ["right", "left", "data"] | ["right", "right", "data"] | 
// ["left", "left", "left", "data"] | ... 2038 more ... | [...]

编译器需要很长时间才能生成它,并且您的编辑器的性能会突然变得非常非常差。让我们将其限制在更易于管理的范围内:

type TreeLeaves = Leaves<Tree, 3>;
// type TreeLeaves2 = ["data"] | ["left", "data"] | ["right", "data"] |
// ["left", "left", "data"] | ["left", "right", "data"] | 
// ["right", "left", "data"] | ["right", "right", "data"]

这会强制编译器停止查看深度 3,因此所有路径的长度最多为 3。


所以,这行得通。很可能 ts-toolbelt 或其他一些实现可能会更加小心,以免导致编译器心脏病发作。所以我不一定说你应该在没有大量测试的情况下在你的生产代码中使用它。

但无论如何,这是你想要的类型,假设你有并且想要Paths

type MyGenericType<T extends object> = 
    keys: Array<Paths<T>>;
;

const test: MyGenericType<NestedObjectType> = 
    keys: [['a'], ['nest', 'c']]

希望有所帮助;祝你好运!

Link to code

【讨论】:

@jclaz 非常感谢!我认为这是我在这里见过的最好的答案之一。 在任何情况下,如果我确实提供了反向类型,它就必须在一个新问题中,因为评论部分不是展示明显不同的代码和解释的好地方。 @jcalz 谢谢你的回答,我真的试过了,这是我的问题:***.com/questions/61644053/… 我在使用Paths 时收到了Type instantiation is excessively deep and possibly infinite (即使深度为3)。对我有用的解决方法是使用infer,将Join&lt;K, Paths&lt;T[K], Prev[D]&gt;&gt; 替换为(Paths&lt;T[K], Prev[D]&gt; extends infer R ? Join&lt;K, R&gt; : never) @MartinŽdila 和@Qtax,我也遇到了这个问题。如果您不需要深入 20 个对象,则可以缩短 Prev 的范围。对于我的项目,我知道它最多只能达到 2 个对象的深度,所以我将我的项目缩短为这样:export type Prev = [never, 0, 1, 2, 3, 4, ...0[]];。小修改:他谈到Prev 的目的,就在他说“其余答案基本相同”的地方。【参考方案2】:

我遇到了类似的问题,当然,上面的答案非常惊人。但对我来说,它有点过头了,而且如上所述对编译器来说是相当繁重的。

虽然不那么优雅,但更易于阅读,我建议使用以下类型来生成类似路径的元组:

type PathTree<T> = 
    [P in keyof T]-?: T[P] extends object
        ? [P] | [P, ...Path<T[P]>]
        : [P];
;

type Path<T> = PathTree<T>[keyof PathTree<T>];

一个主要缺点是,这种类型不能处理像@jcalz 回答中的Tree 这样的自引用类型:

interface Tree 
  left: Tree,
  right: Tree,
  data: string
;

type TreePath = Path<Tree>;
// Type of property 'left' circularly references itself in mapped type 'PathTree<Tree>'.ts(2615)
// Type of property 'right' circularly references itself in mapped type 'PathTree<Tree>'.ts(2615)

但对于其他类型,它似乎做得很好:

interface OtherTree 
  nested: 
    props: 
      a: string,
      b: string,
    
    d: number,
  
  e: string
;

type OtherTreePath = Path<OtherTree>;
// ["nested"] | ["nested", "props"] | ["nested", "props", "a"]
// | ["nested", "props", "b"] | ["nested", "d"] | ["e"]

如果你想强制只引用叶子节点,你可以去掉PathTree类型中的[P] |

type LeafPathTree<T> = 
    [P in keyof T]-?: T[P] extends object 
        ? [P, ...LeafPath<T[P]>]
        : [P];
;
type LeafPath<T> = LeafPathTree<T>[keyof LeafPathTree<T>];

type OtherPath = Path<OtherTree>;
// ["nested", "props", "a"] | ["nested", "props", "b"] | ["nested", "d"] | ["e"]

不幸的是,对于一些更复杂的对象,该类型似乎默认为[...any[]]


当您需要类似于@Alonso's 答案的点语法时,您可以将元组映射到模板字符串类型:

// Yes, not pretty, but not much you can do about it at the moment
// Supports up to depth 10, more can be added if needed
type Join<T extends (string | number)[], D extends string = '.'> =
  T extends  length: 1  ? `$T[0]`
  : T extends  length: 2  ? `$T[0]$D$T[1]`
  : T extends  length: 3  ? `$T[0]$D$T[1]$D$T[2]`
  : T extends  length: 4  ? `$T[0]$D$T[1]$D$T[2]$D$T[3]`
  : T extends  length: 5  ? `$T[0]$D$T[1]$D$T[2]$D$T[3]$D$T[4]`
  : T extends  length: 6  ? `$T[0]$D$T[1]$D$T[2]$D$T[3]$D$T[4]$D$T[5]`
  : T extends  length: 7  ? `$T[0]$D$T[1]$D$T[2]$D$T[3]$D$T[4]$D$T[5]$D$T[6]`
  : T extends  length: 8  ? `$T[0]$D$T[1]$D$T[2]$D$T[3]$D$T[4]$D$T[5]$D$T[6]$D$T[7]`
  : T extends  length: 9  ? `$T[0]$D$T[1]$D$T[2]$D$T[3]$D$T[4]$D$T[5]$D$T[6]$D$T[7]$D$T[8]`
  : `$T[0]$D$T[1]$D$T[2]$D$T[3]$D$T[4]$D$T[5]$D$T[6]$D$T[7]$D$T[8]$D$T[9]`;

type DotTreePath = Join<OtherTreePath>;
// "nested" | "e" | "nested.props" | "nested.props.a" | "nested.props.b" | "nested.d"

Link to TS playground

【讨论】:

伙计,这太美了!!非常感谢!【参考方案3】:

所以上面的解决方案确实有效,但是它们的语法有些混乱,或者给编译器带来了很大的压力。以下是您只需要字符串的用例的编程建议:

type PathSelector<T, C = T> = (C extends  ? 
    [P in keyof C]: PathSelector<T, C[P]>
 : C) & 
    getPath(): string


function pathSelector<T, C = T>(path?: string): PathSelector<T, C> 
    return new Proxy(
        getPath() 
            return path
        ,
     as any, 
        get(target, name: string) 
            if (name === 'getPath') 
                return target[name]
            
            return pathSelector(path === undefined ? name : `$path.$name` as any)
        
    )


type SomeObject = 
    value: string
    otherValue: string
    child: SomeObject
    otherChild: SomeObject

const path = pathSelector<SomeObject>().child.child.otherChild.child.child.otherValue
console.log(path.getPath())// will print: "child.child.otherChild.child.child.otherValue"
function doSomething<T, K>(path: PathSelector<T, K>, value: K)

// since otherValue is a number:
doSomething(path, 1) // works
doSomething(path, '1') // Error: Argument of type 'string' is not assignable to parameter of type 'number'

类型参数 T 将始终保持与原始请求对象相同的类型,以便可以使用它来验证路径是否实际来自指定对象。

C表示路径当前指向的字段的类型

【讨论】:

【参考方案4】:

基于@jcalz's answer 使用conditional types、template literal 字符串、mapped types 和index access types 的递归类型函数,可以使用此ts playground example 进行验证

生成联合类型的属性,包括用点表示法嵌套

type DotPrefix<T extends string> = T extends "" ? "" : `.$T`

type DotNestedKeys<T> = (T extends object ?
     [K in Exclude<keyof T, symbol>]: `$K$DotPrefix<DotNestedKeys<T[K]>>` [Exclude<keyof T, symbol>]
    : "") extends infer D ? Extract<D, string> : never;

/* testing */

type NestedObjectType = 
    a: string
    b: string
    nest: 
        c: string;
    
    otherNest: 
        c: string;
    


type NestedObjectKeys = DotNestedKeys<NestedObjectType>
// type NestedObjectKeys = "a" | "b" | "nest.c" | "otherNest.c"

const test2: Array<NestedObjectKeys> = ["a", "b", "nest.c", "otherNest.c"]

这在使用 mongodb 或 firebase firestore 等文档数据库时也很有用,可以使用点表示法设置单个嵌套属性

使用 mongodb

db.collection("products").update(
    _id: 100 ,
    $set:  "details.make": "zzz"  
)

火力基地

db.collection("users").doc("frank").update(
   "age": 13,
   "favorites.color": "Red"
)

这个更新对象可以使用这个类型来创建

然后打字稿会指导你,只需添加你需要的属性

export type DocumentUpdate<T> = Partial< [key in DotNestedKeys<T>]: any & T> & Partial<T>

您还可以更新 do 嵌套属性生成器以避免显示嵌套属性数组、日期...

type DotNestedKeys<T> =
T extends (ObjectId | Date | Function | Array<any>) ? "" :
(T extends object ?
     [K in Exclude<keyof T, symbol>]: `$K$DotPrefix<DotNestedKeys<T[K]>>` [Exclude<keyof T, symbol>]
    : "") extends infer D ? Extract<D, string> : never;

【讨论】:

嗨,很好的解决方案!有没有办法只显示直接子节点而不是所有可能的路径?例如:a、b、nest、otherNest,而不是nest、nest.c 等。所以nest.c只有在用户输入“nest”时才会出现。 @DoneDeal0 是的,在这种情况下,您只需传递原始类型即可。 我尝试用typeof objectName 输入函数的参数,但它不起作用。也许我做错了什么? codesandbox.io/s/aged-darkness-s6kmx?file=/src/App.tsx 最后一个问题,是否可以在您的类型中允许未知字符串同时保持自动完成?如果我用Partial&lt; [key in KeyPath&lt;T&gt;]: any &amp; T&gt; &amp; string 包装NestedObjectKeys - 如您的示例中所建议的那样 - 自动完成不再可用。但是,如果没有这种额外的输入,打字稿会拒绝任何与原始对象结构不匹配的字符串。因此它与动态类型不兼容(例如:"PREFIX." + dynamicKey 会返回错误)。我用你的代码做了一个沙箱:codesandbox.io/s/modest-robinson-8b1cj?file=/src/App.tsx. 如果你需要nest 不仅nest.anest.b 结果,你可以改变DotNestedKeys 像这样: type DotNestedKeys = (T extends object ? [K in Exclude]: $K$DotPrefix&lt;DotNestedKeys&lt;T[K]&gt;&gt; | K [Exclude]: "") 扩展推断 D ?提取 : 从不;【参考方案5】:

Aram Becker 的回答添加了对数组和空路径的支持:

type Vals<T> = T[keyof T];
type PathsOf<T> =
    T extends object ?
    T extends Array<infer Item> ?
    [] | [number] | [number, ...PathsOf<Item>] :
    Vals<[P in keyof T]-?: [] | [P] | [P, ...PathsOf<T[P]>]> :
    [];

【讨论】:

【参考方案6】:
import  List  from "ts-toolbelt";
import  Paths  from "ts-toolbelt/out/Object/Paths";

type Join<T extends List.List, D extends string> = T extends []
  ? ""
  : T extends [(string | number | boolean)?]
  ? `$T[0]`
  : T extends [(string | number | boolean)?, ...infer U]
  ? `$T[0]` | `$T[0]$D$Join<U, D>`
  : never;

export type DottedPaths<V> = Join<Paths<V>, ".">;

【讨论】:

不适用于自引用类型 @m0onspell 添加了更简洁的版本。希望这会有所帮助。

以上是关于打字稿:嵌套对象的深度键的主要内容,如果未能解决你的问题,请参考以下文章

基于嵌套对象内属性的打字稿联合

我们如何在打字稿中获得嵌套对象类型

使用唯一键展平/规范化深度嵌套的对象

如何将带有嵌套数组的 JSON 对象映射到打字稿模型中?

具有定义值的打字稿动态对象键

打字稿如何在对象中键入自定义键