如何键入具有相关约束的泛型接口的记录



>我正在使用解析器工具包(Chevrotain)来编写查询语言,并希望允许用户扩展其功能。我拥有执行此操作所需的所有部分,但正在努力为此扩展行为定义类型。我希望能够键入配置对象,以便使用Typescript的用户将获得方便的IDE帮助,断言他们的输入是正确的;这似乎是可能的(或非常接近可能),所以我一直在尝试编写类型(而不是在运行时断言)。

一些配置的(微不足道的)示例:

ops: {
equal: {
lhs: {
type: 'string',
from: v => String(v),
},
rhs: {
type: 'number',
from: v => v.toString(),
},
compare: (lhs, rhs) => lhs === rhs,
}
equal: { /*...*/ }
}

我希望以下事情是真的:

  1. from的参数的类型与type属性的字符串文本值相关。我已经设法通过几种方式完成此操作,其中最干净的是一个简单的类型,例如:
type ArgTypes = {
string: string,
number: number,
ref: any, // the strings don't have to be typescript types, and the types could be more complex
}
  1. lhsrhs字段可能接收彼此不同的类型,并生成彼此不同的类型。

  2. compare函数将lhsrhs属性的输出作为输入,并返回布尔值。

我已经能够在单个运算符(equal)级别键入内容,但是我无法将其扩展到运算符的对象包中。这是一个 Playground 链接,我试图使用泛型和子类型一次构建一个片段:尝试 N。在这次尝试中,一旦我到达对象映射位,我似乎就无法坚持狭窄的类型;可能无法在第 105 行为Ops提供有效的类型签名?

另一个(灵感来自在 TypeScript 中作为参数传递时防止对象文字类型扩大),我试图一次完成所有操作,只是为每件该死的事情添加类型参数:尝试 N+1。这几乎有效,但是当您取消注释类型签名中的"比较"行时,(以前工作的)窄类型变得通用。(例如,文字"number"变为string)

可以这样做还是应该放弃?如果是这样,如何?

这里的根本问题是 TypeScript 同时推断泛型类型参数和上下文类型函数参数的能力有限。 推理算法是一组合理的启发式算法,适用于许多常见用例,但它不是一个完全统一的算法,保证为所有泛型类型参数和所有未注释的值分配正确的类型,正如 microsoft/TypeScript#30134 中提议的那样(但尚未或可能从未实现)。

因此可以推断出泛型类型参数:

declare function foo<T>(x: (n: number) => T): T
foo((n: number) => ({ a: n })) // T inferred as {a: number}

并且可以推断出未注释的函数参数:

declare function bar(f: (x: { a: number }) => void): void;
bar(x => x.a.toFixed(1)) // x inferred as {a: number}

并且有一些能力可以同时执行这两项操作,特别是如果您要求从多个函数参数进行推断并且推理流程从左到右:

declare function baz<T>(x: (n: number) => T, f: (x: T) => void): void;
baz((n) => ({ a: n }), x => x.a.toFixed(1))
// n inferred as number, T inferred as {a: number}, x inferred as {a: number}

但在某些情况下,这不起作用。 在 TypeScript 4.7 之前,具有单个函数参数的以下变体将无法根据需要推断:

declare function qux<T>(arg: { x: (n: number) => T, f: (x: T) => void }): void;
qux({ x: (n) => ({ a: n }), f: x => x.a.toFixed(1) })
// TS 4.6, n inferred as number, T failed to infer, x failed to infer
// TS 4.7, n inferred as number, T inferred as {a: number}, x inferred as {a: number}

此问题已在 TypeScript 4.7 中通过 Microsoft/TypeScript#48538 修复。 但它离完美的算法还很远。

例如,这种推理在与来自映射类型的推理结合使用时会崩溃。 从映射类型进行简单推理如下所示:

declare function frm<T>(obj: { [K in keyof T]: (n: number) => T[K] }): void;
frm({ p: n => ({ a: n }) });
// T inferred as {p: {a: number}}

但是尝试将其与函数参数的同时上下文推理相结合,结果会失败:

declare function doa<T>(
obj: { [K in keyof T]: { x: (n: number) => T[K], f: (x: T[K]) => void } }
): void;
doa({ p: { x: (n) => ({ a: n }), f: x => x.a.toFixed(1) } });
// TS 4.8, T failed to infer
doa<{ p: { a: number } }>(
{ p: { x: (n) => ({ a: n }), f: x => x.a.toFixed(1) } }); // okay

因此,不幸的是,如果您尝试表达与类型的关系涉及许多涉及上下文类型的复杂推理路径,则可能会失败。


您的示例代码正在尝试同时从映射类型和回调参数推理进行推理,因此失败。 好吧,像这样的呼叫签名

function ops<T extends { [key: string]: any }, O extends Ops<T>>(spec: O): O {
return spec;
}

永远不会起作用,因为通用约束不能用作推理站点;请参阅 Microsoft/TypeScript#7234。 编译器无法从以下事实推断出TO extends Ops<T>. 我们需要将其更改为类似

function ops<T>(spec: Ops<T>): Ops<T> { // infer from mapped type
return spec;
}

然后我们可以得到推论...虽然他的错误消息有点奇怪,因为您将类型替换为InferArgs中的never

const okay = ops({
one: { // okay
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: number) => v.toString()
},
compare: (lhs: string, rhs: string) => lhs !== rhs
}, two: { // error!
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: string) => v.toString()
},
compare: (lhs: string, rhs: string) => lhs !== rhs
},
three: { // error!
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: number) => v.toString()
},
compare: (lhs: number, rhs: string) => lhs !== rhs
},
four: { // error
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: number) => v.toString()
},
compere: (lhs: string, rhs: string) => lhs !== rhs,
}
})

我想我可能会尝试将故障替换为"正确"类型而不是never,这可能看起来像

function ops<T>(ops:
T & { [K in keyof T]: T[K] extends {
lhs: { type: infer KL extends keyof ArgTypes, from: (arg: any) => infer RL },
rhs: { type: infer KR extends keyof ArgTypes, from: (arg: any) => infer RR }
} ? Args<KL, KR, RL, RR> : Args<keyof ArgTypes, keyof ArgTypes, unknown, unknown> }
): T { return ops };

这导致这种情况,错误放置稍好一些:

const okay = ops({
one: { // okay
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: number) => v.toString()
},
compare: (lhs: string, rhs: string) => lhs !== rhs
}, two: {
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: string) => v.toString() // error, wrong v
},
compare: (lhs: string, rhs: string) => lhs !== rhs
},
three: {
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: number) => v.toString()
},
compare: (lhs: number, rhs: string) => lhs !== rhs // error, wrong lhs
},
four: { // error. missing compare
lhs: {
type: 'string',
from: (v: string) => String(v)
},
rhs: {
type: 'number',
from: (v: number) => v.toString()
},
compere: (lhs: string, rhs: string) => lhs !== rhs,
}
})

这就是我能做的最好的事情,如果你需要有一个包含所有属性的单个对象文字,一次所有属性。


但是,假设这不是您为其提供类型的现有代码,则不需要走这条路。 正如你提到的,你可以使用构建器模式或类似的东西让用户分阶段构建他们的对象,其中每个阶段只需要一点推理就可以成功,比如多次使用args()。 例如:

class BuildOps<T extends Record<keyof T, Args<any, any, any, any>>> {
constructor(public ops: T) { }
add<K extends PropertyKey, KL extends keyof ArgTypes,
KR extends keyof ArgTypes, RL, RR>(
key: K,
args: Args<KL, KR, RL, RR>
): BuildOps<T & Record<K, Args<KL, KR, RL, RR>>> {
return new BuildOps({ ...this.ops, [key]: args } as any);
}
build(): { [K in keyof T]: T[K] } {
return this.ops;
}
static emptyBuilder: BuildOps<{}> = new BuildOps({})
static add = BuildOps.emptyBuilder.add.bind(BuildOps.emptyBuilder);
}

可以这样使用:

const myOps = BuildOps.add("one", {
lhs: { type: 'string', from: v => String(v) },
rhs: { type: 'number', from: v => v.toFixed(2) },
compare: (lhs, rhs) => lhs !== rhs
}).add("two", {
lhs: { type: 'number', from: v => v > 3 },
rhs: { type: 'boolean', from: v => v ? 0 : 1 },
compare(l, r) { return (l ? 0 : 1) === r }
}).build();
/* const myOps: {
one: Args<"string", "number", string, string>;
two: Args<"number", "boolean", boolean, 1 | 0>;
} */

这与推理算法的功能配合使用,而不是反对它。

操场链接到代码

最新更新