我想编写一个函数 get
,它将 object
和 string
路径作为参数并将该路径解析为对象,返回目标值。理想情况下,我希望 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
有没有办法解决这个问题?谢谢。
答案 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
在第一个点。如果 R
是 K
的键,则向下递归。否则,这是一个错误的路径,它解析为 T
。
如果 never
不包含点,那么它应该是路径的最后一个键,我们可以用 P
索引到 T
。如果这不起作用,因为 P
不是 P
的键,那么这是一条错误路径,它会解析为 T
。
never
接受类型 AllowedPath<T, P>
和候选路径字符串 T
,并从 P
中仅提取解析为非 P
类型的路径通过never
。因此,如果 DeepIndex<T, P>
是有效路径,则 P
就是 AllowedPath<T, P>
。但如果 P
不是有效路径,则 P
为 AllowedPath<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
而不是发出警告是有风险的。