我们希望exists(obj, paths)
函数是generic-user-defined type guard function.如果obj
是泛型类型T
,paths
是泛型类型K[]
,那么我们希望true
返回值将obj
的范围缩小到T
的子类型,这些子类型已知在由K
表示的路径上具有非nullish属性.
这意味着我们需要编写一个the Record<K, V>
utility type的"嵌套"版本,将K
视为由点路径组成的union,而不是普通键的联合.我们就叫它NestedRecord<K, V>
吧.我们希望它是这样的:
type Example = NestedRecord<
"z.y.x" | "z.w.v" | "u.t" | "u.t.s" | "r.q" | "p",
Date
>;
/* type Example = {
z: {
y: { x: Date; };
w: { v: Date; };
};
u: { t: Date & { s: Date; }; };
r: { q: Date; };
p: Date;
}*/
由于非空值正好对应于"空对象类型"{}
(请参见How to undestand relations between types any, unknown, {} and between them and other types?),因此我们希望exists()
的调用签名如下所示:
declare function exists<T extends object, K extends string>(
obj: T, paths: K[]
): obj is T & NestedRecord<K, {}>;
所以现在我们要给NestedRecord<K, V>
下定义.
这里有一种方法:
type NestedRecord<K extends string, V> = [K] extends [never] ? unknown : {
[P in (K extends `${infer K0}.${string}` ? K0 : K)]:
(P extends K ? V : unknown) &
NestedRecord<K extends `${P}.${infer KR}` ? KR : never, V>
}
这是使用key remapping in mapped types和template literal types来解析K
中的密钥.NestedRecord<K, V>
的关键字应该只是K
的第一个点之前的部分,或者K
的任何没有点的部分.对于没有点的任何K
,值NestedRecord<K, V>
应该是V
,对于从当前关键点开始的第一个点之后的K
的部分,值应该是intersected和NestedRecord<K, V>
.
哦,NestedRecord<never, V>
应该是unknown
而不是{}
,这样我们就不会有很多令人讨厌的交叉点了,{}
在末尾类型.
您可以验证对于上面的Example
,此定义的行为是否符合预期.
最后,我们需要实施exists()
个.或许有一种方法可以做到这一点:
function exists<T extends object, K extends string>(
obj: T, paths: K[]
): obj is T & NestedRecord<K, {}> {
return paths.every(p => p.split(".").reduce((a, k) => (a ?? a[k]), obj) != null);
}
我使用every()
和reduce()
数组方法来验证object
在paths
中的每条路径上都有一个非空值.
让我们在您的示例object
上try 一下:
object;
// ^? var object: Foo
if (exists(object, ["a.ii", "c"])) {
object;
// ^? var object: Foo & { a: { ii: {}; }; c: {}; }
let value = object.a.ii;
// ^? let value: number
console.log(value); // 1
}
看上go 不错.在受保护的代码块中,object
已经从Foo
缩小到Foo & { a: { ii: {}; }; c: {}; }
,这意味着当我们深入object.a.ii
时,类型是(number | undefined) & {}
,这只是number
,这是所需的.
Playground link to code个