您的示例代码存在一些问题,这些问题阻碍了您找到解决方案。我们将依次修复每个问题,直到我们有工作代码。
首先,您对Arg<N> 的定义仅对name 进行强类型化,对应于一个属性key。如果您希望编译器(实际上是您的代码)知道关于属性的 value 类型的任何信息,那么您需要扩展 Arg 的定义以包含它。例如:
class Arg<N extends string, V> {
public constructor(protected name: N, protected value: V) { }
}
这里我们添加了第二个generic类型参数V来对应值的类型。此外,由于 TypeScript 的类型系统是 structural 而不是 nominal,这意味着您不能只添加一个类型参数 named V 并期望它约束任何东西。如果您希望编译器认为Arg<N, V> 依赖于N 和V,它必须在结构上 或weird things might happen。最简单的方法是给Arg 一个V 类型的属性。也许在您的实际代码中,Arg<N, V> 不会保留V 类型的值,但它可能会做与V 类型相关的某事。也许是validate(candidate: V): boolean; 之类的方法。但是对于这个例子,我只是给它一个属性。
我们现在可以扩展Int 和Bool:
class Int<N extends string> extends Arg<N, number> { }
class Bool<N extends string> extends Arg<N, boolean> { }
并且编译器肯定会明白Int<N> 代表number 属性,Bool<N> 代表boolean 属性。这种理解对于让下面的其余代码正常运行至关重要。
这也意味着我们需要对你的 create 函数做一些事情:
const createInt = <N extends string>(name: N) => new Int<N>(name, 0)
const createBool = <N extends string>(name: N) => new Bool<N>(name, false)
现在,下一个问题是 TypeScript 中没有与您的 Options 类型完全对应的 特定 类型。您可以制作一些特定的东西来接受options 的所有有效值,但不幸的是它也会接受无效值:
type TooWideOptions = {
args: readonly Arg<string, any>[],
execute: (args: never) => void
}
问题在于,对于任何给定类型的args 属性,execute 方法的回调参数都有一个特定的关联对象类型,而TooWideOptions 没有捕获此约束。如果不使 Options 泛型化,目前无法在 TypeScript 中捕获此约束:
type Options<A extends readonly Arg<any, any>[]> = {
args: readonly [...A],
execute: (args: ArgArrayToObject<A>) => void
}
现在我们有了Options<A>,其中A 表示args 属性的类型(Arg 元素的数组),其中execute 方法的回调参数是ArgArrayToObject<A> 类型,我们需要定义它。
我们怎样才能做到这一点?这是一个可能的实现:
type ArgArrayToObject<A extends readonly Arg<any, any>[]> =
{ [T in A[number]
as T extends Arg<infer N, any> ? N : never
]: T extends Arg<any, infer V> ? V : never } extends
infer O ? { [K in keyof O]: O[K] } : never;
此类型遍历A 的所有元素并构建mapped type whose key names are remapped。对于某些N 和V,每个元素的类型为Arg<N, V>,我们将N 设为属性键类型,将V 设为值类型。例如:
type Test = ArgArrayToObject<[
Arg<"str", string>,
Arg<"num", number>,
Arg<"dat", Date>
]>;
/* type Test = {
str: string;
num: number;
dat: Date;
} */
所以现在我们有了一个通用的Options<A>。如果您能以某种方式表示所有可能的A 类型的无限union,那么您可以构建您想要的特定Options 类型。但这需要所谓的存在量化的泛型,TypeScript 并不直接支持(请参阅microsoft/TypeScript#14466 获取功能请求)。
相反,我们将不得不以通常的方式保留Options<A> 泛型,因此您必须为Options<A> 类型的每个值选择一个特定的A。您可以强制开发人员手动annotate 的确切类型,包括A 的特定类型,但这既乏味又多余:
const explicitOptions: Options<[Int<"age">, Bool<"isStudent">]> = {
args: [
createInt('age'),
createBool('isStudent')
],
execute: args => {
/* (parameter) args: { age: number; isStudent: boolean; } */
args.age.length // error, no 'length' on number
args.name // error, no 'name' on args
args.isStudent // okay
}
}
这可以随心所欲,但同时写出Options<[Int<"age">, Bool<"isStudent">] 和[createInt('age'), createBool('isStudent')] 并不好玩。
最后解决这个问题的方法是引入一个通用的辅助函数,让编译器为你推断A:
const asOptions = <A extends readonly Arg<any, any>[]>(options: Options<A>) => options;
这个函数只是返回它的输入,所以它在运行时没有任何用途。但是在编译时,它会将options 限制为Options<A> 类型,因为编译器会推断出一些A:
const options = asOptions({
args: [
createInt('age'),
createBool('isStudent')
],
execute: args => {
/* (parameter) args: { age: number; isStudent: boolean; } */
args.age.length // error, no 'length' on number
args.name // error, no 'name' on args
args.isStudent // okay
}
});
您可以验证options 是否仍与之前手动注释的版本属于同一类型,而无需强制您将其写出:
// const options: Options<[Int<"age">, Bool<"isStudent">]>
就是这样。现在我们有了工作代码,可以表示和强制执行args 属性和execute 方法之间的约束,该值是Options-like 类型的值。
Playground link to code