使用模板文字类型解构泛型参数中的字符串

时间:2021-04-17 03:42:54

标签: typescript

我想编写一个函数 get,它将 objectstring 路径作为参数并将该路径解析为对象,返回目标值。理想情况下,我希望 TypeScript 能够猜测结果的类型。

因此,例如,这是有效的:

declare function get<T extends object, K extends keyof T>(entity: T, path: K): T[K]
declare function get(entity: object, path: string): any

const value = get({ foo: { bar: 45 }, baz: true }, "baz") // type boolean

现在嵌套对象变得有趣了。这有效:

declare function get<T extends object, K extends T extends Record<infer A, infer B> ? `${A & string}.${(keyof B) & string}` : never>
    (entity: T, path: K): T extends Record<infer A, infer B> ? T[A][keyof B] : never
    
declare function get<T extends object, K extends keyof T>(entity: T, path: K): T[K]
declare function get(entity: object, path: string): any

const value = get({ foo: { bar: 45 } }, "foo.bar") // type number

但是,如果我向输入对象添加一个属性,它就会停止工作:

const value = get({ foo: { bar: 45 }, baz: true }, "foo.bar") // type any

Link to Playground

有没有办法解决这个问题?谢谢。

1 个答案:

答案 0 :(得分:1)

我倾向于将您要执行的操作表示为单个 recursive DeepIndex<T, P> 类型的操作,该操作采用类型 T 和虚线路径字符串 {{1}并返回使用 P 表示的路径对 T 类型的值进行索引时获得的类型。这里的第一个实现是限制性的:它会检查 P 是否是一个有效的路径(并且另外不指向 P 类型的属性)并在 never 上发出警告,如果它不是:

get()

type DeepIndex<T, P extends string> = P extends `${infer K}.${infer R}` ? K extends keyof T ? DeepIndex<T[K], R> : never : P extends keyof T ? T[P] : never; type AllowedPath<T, P extends string> = P extends any ? DeepIndex<T, P> extends never ? never : P : never; declare function get<T extends object, P extends string>( entity: T, path: AllowedPath<T, P>): DeepIndex<T, P>; 通过检查路径 DeepIndex<T, P> 是否包含点来工作。

如果 P 确实包含一个点,那么 template literal 推理分裂 P 以获得第一个点之前的键 P,其余的 K 在第一个点。如果 RK 的键,则向下递归。否则,这是一个错误的路径,它解析为 T

如果 never 不包含点,那么它应该是路径的最后一个键,我们可以用 P 索引到 T。如果这不起作用,因为 P 不是 P 的键,那么这是一条错误路径,它会解析为 T

never 接受类型 AllowedPath<T, P> 和候选路径字符串 T,并从 P 中仅提取解析为非 P 类型的路径通过never。因此,如果 DeepIndex<T, P> 是有效路径,则 P 就是 AllowedPath<T, P>。但如果 P 不是有效路径,则 PAllowedPath<T, P>。请注意,如果 never 是有效路径和无效路径的并集,则输出将只是有效路径。

您可以验证 P 是否按上述方式工作:

get()

对于 const obj = { foo: { bar: 45 }, baz: true }; get(obj, "foo.bar").toFixed(2); // okay get(obj, "foo").bar.toFixed(2); // okay get(obj, "bar"); // error! get(obj, "foo."); // error! get(obj, Math.random() < 0.5 ? "..." : "baz"); // error! get(obj, "foo.bar.toFixed") // this is okay, not sure if you want that, but ?‍♂️ 对象,obj"foo" 都被接受并产生适当的输出类型。对于像普通 "foo.bar""bar"(带有结束点)这样的无效路径,编译器错误说这些路径不能分配给 "foo."(我承认这不是最用户友好的错误。它可以有所改进,但这可能超出了此处的范围)。还要注意,如果路径是无效和有效路径的联合,例如 never(坏)和 "..."(好),编译器错误会抱怨坏部分并说它不能分配给"baz"

最后,请注意 "baz" 允许您“离开”声明类型的末尾,因为即使像 DeepIndex 这样的原始类型也有方法,因此 number 解析为函数-类型属性。 (如果这是一个问题,也可以通过强制 "foo.bar.toFixed" 在继续之前检查 DeepIndex 是否是一个对象来改进它。但同样,可能超出范围。)


在您的评论中,您表明您希望接受所有路径,并且如果路径无效,则输出类型为 T。这个宽松的版本更容易实现,通过完全摆脱 any(因为我们永远不想拒绝任何东西)并改变 AllowedPath 的实现,使其解析为 DeepIndex 而不是 {{ 1}} 在路径错误的情况下:

any

之前所有“okay”的情况都一样,但以前导致错误的情况现在产生never。你应该小心:the --noImplicitAny compiler flag 不会捕捉到这些(这里的 type DeepIndex<T, P extends string> = P extends `${infer K}.${infer R}` ? K extends keyof T ? DeepIndex<T[K], R> : any : P extends keyof T ? T[P] : any; declare function get<T extends object, P extends string>( entity: T, path: P): DeepIndex<T, P> const obj = { foo: { bar: 45 }, baz: true }; get(obj, "foo.bar").toFixed(2); // okay get(obj, "foo").bar.toFixed(2); // okay get(obj, "bar"); // any get(obj, "foo."); // any get(obj, Math.random() < 0.5 ? "..." : "baz"); // any 是明确的),所以使用它的人可能最终会在没有意识到的情况下做不安全的事情:

any

你看到问题了吗?可能不是,但该路径中的“a”实际上是 "а", a Cyrillic letter,因此 any 是无效路径。编译器不会抱怨,但会返回 get(obj, "foo.bаr").toFixed(2); // okay ,这让您可以使用 "foo.bаr"any 或其他方式对其进行索引,并且您只会在运行时发现问题。

这可能不太可能(尤其是意外或恶意的字符集更改),但我只想指出,回退到 toFixed 而不是发出警告是有风险的。


Playground link to code

相关问题