为什么 TypeScript 无法根据泛型推断回调函数参数类型
我原本期望 TypeScript 在以下示例中推断回调参数的类型为 number,但我却收到一个错误。
type EventWatch = {
<K extends keyof User>(eventName: `${string & K}Changed`, callback: (e: User[K]) => void): void;
};
type User = {
firstname: string;
lastname: string;
age: number;
on: EventWatch;
}
let john: User = {
firstname: 'John',
lastname: 'Doe',
age: 33,
on(eventName, callback) {
if (eventName == 'ageChanged') {
callback(133) //ERROR:
/*
TS2345: Argument of type 'number' is not assignable to parameter of type 'User[K]'.
Type 'number' is not assignable to type 'never'.
*/
}
}
};
它可以正确地推断 eventName 类型,并识别它应该是三个值之一:"firstnameChanged"、"lastnameChanged"、"ageChanged"。但它无法确定其具体值。
我传递给函数的参数能够被正确解析,如果我传递错误的参数,它会像应该的那样报错。
john.on('ageChanged', e => {}); //No Error
john.on('salaryChanged', e => {}); //Error: There is no such a key named salary.
为什么它不会推断回调参数类型?
答案#1
问题在于 TypeScript 不使用控制流分析来缩小或约束泛型类型参数。因此,当你检查 eventName == 'ageChanged' 时,虽然 eventName 变量的类型可能被缩小,但 K 没有任何变化。在检查之前,编译器知道 K extends keyof User,而这个检查并不会改变这一点。特别是,K 不会被重新约束为 K extends "age"(实际上这样的缩小是不安全的,因为也许 K 是完整的 keyof User 联合类型)。因此,类型为 (e: User[K]) => void 的 callback 并不被视为 (e: number) => void,你就会得到一个错误:
on(eventName, callback) {
// (parameter) eventName: `${K}Changed`
// (parameter) callback: (e: User[K]) => void
if (eventName == 'ageChanged') {
// (parameter) eventName: `${K}Changed` still
// (parameter) callback: (e: User[K]) => void still
callback(133) // error
}
}
在 GitHub 上有几个功能请求,希望能够基于控制流来缩小泛型类型参数。请参见 microsoft/TypeScript#24085 和 microsoft/TypeScript#27808,其中包含了一些请求(并且它们链接到了其他请求)。没人知道什么时候甚至是否会实现这个功能。现在,最简单的方式是使用类型断言来抑制错误,告诉编译器它不知道的内容:
on(eventName, callback) {
if (eventName == 'ageChanged') {
(callback as (e: number)=>void)(133) // okay
}
}
但是这不再是类型安全的了。
在这里,你可以考虑不给 on() 使用泛型,而是给它一个由元组组成的联合的剩余参数,因为可能允许的 eventName/callback 对类型只有有限的列表。而且由于 eventName 参数是一个字符串字面量类型,这意味着你的联合将是一个区分联合,检查 eventName 时会自动缩小 callback 的类型。
这个想法大致是这样的:
type EventWatchParams =
[eventName: "firstnameChanged", callback: (e: string) => void] |
[eventName: "lastnameChanged", callback: (e: string) => void] |
[eventName: "ageChanged", callback: (e: number) => void] |
[eventName: "onChanged", callback: (e: EventWatch) => void];
type EventWatch = {
(...args: EventWatchParams): void;
};
注意,这意味着你只能用两个参数调用 EventWatch,其中第一个和第二个参数的类型相互依赖。当然,你不会想手动写出 EventWatchParams;你希望编译器能自动根据 User 计算它。你可以通过 分布式对象类型(在 ms/TS#47109 中提出)来实现这一点,即立即索引到映射类型,如下所示:
type EventWatchParams = {
[K in keyof User]: [eventName: `${K}Changed`, callback: (e: User[K]) => void]
}[keyof User]
您可以验证,以这种方式定义的EventWatchParams与之前的联合是等价的。
那么,这个基于剩余元组的判别联合版本是如何工作的呢?在 TypeScript 4.5 及以下版本中,你需要使用 ...args 并通过数字索引访问它。虽然有点丑,但它已被验证为类型安全:
on(...args) {
if (args[0] == 'ageChanged') {
args[1](133) // okay
}
}
类型收窄起作用;如果 args[0] 是 ageChanged,那么 args[1] 会被收窄为 (e: number) => void。
一旦 TypeScript 4.6 发布,你可以通过将 args 解构到你想要的变量名中来稍微改进这一点,并且控制流分析仍然会有效:
// TS4.6+
on(...args) {
const [eventName, callback] = args;
if (eventName == 'ageChanged') {
callback(133) // okay
}
}
不过,这已经是你能得到的最好的了。理想的版本是你可以写 on(eventName, callback) 而不是 on(...args),这正在等待 microsoft/TypeScript#46680 的更新,目前还不清楚什么时候会解决这个问题。
代码的 Playground 链接
