事件 API:类型关联、收窄与参数配对
从 on 的泛型推导到 emit 的元组联合,理解回调返回值、联合事件名与完整参数配对。
本章目录CONTENTS ↓
事件 API 有两个常见需求:订阅时,由事件名决定回调收到的数据;发送时,让事件名与数据正确配对。保存事件携带 id,进度事件携带 percent。类型设计需要表达这种对应关系,并在事件名尚未确定时保留安全边界。
本章先用事件映射表描述关系,再讨论回调返回值和收窄,最后用完整参数元组的联合约束发送操作。所有 declare 声明都只描述类型,不提供运行时订阅或分发实现。
订阅事件:让事件名决定回调参数
如果每个回调都接收全部事件数据的联合,即使订阅明确的 saved 事件,也需要重复判断。可以用事件映射表把名称和载荷放在一起,再让同一个泛型参数连接两个参数位置。
type AppEvents = {
saved: { id: string };
progress: { percent: number };
};
declare function on<K extends keyof AppEvents>(
name: K,
handler: (event: AppEvents[K]) => void
): void;
on("saved", event => {
console.log(event.id.toUpperCase());
// @ts-expect-error saved 的载荷没有 percent
console.log(event.percent);
});本次实参的类型是字面量 “saved”,因此 K 推导为 “saved”;它满足 keyof AppEvents 的约束,再通过 AppEvents[“saved”] 得到 { id: string }。回调参数 event 从所在位置的期望函数类型获得上下文类型,无需重复标注。
keyof AppEvents 是合法键的范围,K 则是本次调用推导出的键类型。约束并不意味着每次 K 都会取全部键的联合。
已有事件联合时,用 Extract 查找成员
有些代码已经用 kind 描述完整事件,此时可以直接从联合中提取成员,无需先改成映射表。
type AppEvent =
| { kind: "saved"; id: string }
| { kind: "failed"; error: string }
| { kind: "progress"; percent: number };
type EventOf<K extends AppEvent["kind"]> =
Extract<AppEvent, { kind: K }>;
declare function onUnion<K extends AppEvent["kind"]>(
kind: K,
handler: (event: EventOf<K>) => void
): void;
onUnion("saved", event => {
console.log(event.id.toUpperCase());
});Extract 逐成员检查第一个参数 AppEvent,匹配后保留完整成员。EventOf<"saved"> 包含 kind 和 id;映射表版本的 AppEvents[“saved”] 只包含 id。根据已有数据结构选择即可。Extract 的分配规则见条件类型基础章。
回调的 void:表达如何使用返回值
订阅系统通常只执行回调,不依赖回调返回的结果。签名中的两个 void 分别描述不同函数:内层表示不使用 handler 的返回值,外层描述 on 自己的返回类型。
const makeLabel = () => "saved";
const notify: () => void = makeLabel;
const label = makeLabel(); // 静态类型 string
const result = notify(); // 静态类型 void,运行时值仍是 "saved"目标函数类型为 () => void 时,允许接收有返回值的实现;通过这个目标类型调用,结果的静态类型是 void。赋值不会修改原函数,也不会把运行时返回值变成 undefined。void 与 undefined 是不同类型。
这使得忽略返回值的回调可以直接复用已有函数。例如 push 返回数组的新长度,但 forEach 不使用这个返回值。
const collected: number[] = [];
[1, 2, 3].forEach(value => collected.push(value));显式标注实现时,会检查返回表达式
// @ts-expect-error 实现显式标注 void,不能返回 string
const notifyDirect = (): void => "saved";
const notifyWithoutValue = (): void => {};前一个例子给变量标注目标函数类型;这里把返回类型直接标在函数实现上,因此返回表达式必须满足实现的返回类型要求。不要把 void 理解成必须返回一个名为 void 的值。
赋值方向取决于目标承诺
以下例子参数相同,只比较返回类型。
const getCount = () => 42;
const run: () => void = getCount; // 可以忽略 number 结果
const empty: () => void = () => {}; // 无显式返回值也可以
// @ts-expect-error run 的签名不承诺返回 number
const readCount: () => number = run;
// @ts-expect-error 特殊规则针对函数赋值,不是 string 值赋给 void
const invalidValue: void = "saved";目标要求 number 时,调用者需要能够使用数值结果;() => void 无法提供这个保证。目标为 void 的特殊返回规则也不会免除参数兼容性检查。若 API 确实使用回调的结果,应描述实际结果类型;any 虽然可以用于返回类型,但不能准确表达“忽略返回值”的约定。
联合事件名:推导与收窄发生在哪里
事件名可能来自运行时选择。调用时只能确认联合类型,回调就必须能处理对应的载荷联合。
const eventName =
Math.random() > 0.5 ? "saved" : "progress";
on(eventName, event => {
// K = "saved" | "progress"
// event: { id: string } | { percent: number }
// @ts-expect-error 不是所有成员都有 percent
console.log(event.percent);
});联合索引 AppEvents[“saved” | “progress”] 得到两个属性值类型的联合。它没有承诺当前数据一定是进度事件。
在回调里检查载荷
on(eventName, event => {
if ("percent" in event) {
console.log(event.percent.toFixed(0));
} else {
console.log(event.id.toUpperCase());
}
});本例 percent 只在进度载荷中声明,因此可以用 in 收窄。已有 kind 的事件联合也可以直接检查 event.kind:
const selectedKind = Math.random() > 0.5 ? "saved" : "failed";
onUnion(selectedKind, event => {
if (event.kind === "saved") {
console.log(event.id);
} else {
console.log(event.error);
}
});提前返回也能帮助后续路径收窄。移除下面的 return 后,两种载荷都可能到达最后一行,直接访问 percent 就会报错。
on(eventName, event => {
if ("id" in event) {
console.log(event.id);
return;
}
console.log(event.percent);
});检查外部名字,不会在这里重新推导 K
on(eventName, event => {
if (eventName === "progress") {
// @ts-expect-error 此签名没有提供两个变量的联动收窄
console.log(event.percent);
}
});该调用的 K 是联合类型,回调接收的已经是载荷联合。签名没有保留“外部名字的每个取值对应某个载荷成员”的逐项收窄关系。回调中的判断不会重新推导这次调用的 K。这个结论限定于此签名和调用方式,不应推广为所有关联参数都不能联动收窄。
把判断放到调用之前
if (eventName === "progress") {
on(eventName, event => {
console.log(event.percent); // 可以通过
});
}此处先把 eventName 收窄为字面量 “progress”,再调用 on。调用位置的实参类型使 K 推导为 “progress”,AppEvents[K] 因此为 { percent: number }。区别在于调用时可用的静态信息。
发送事件:用完整元组保留参数配对
发送时,事件名与数据必须属于同一套合法组合。直接套用泛型索引写法,对联合事件名存在边界:
declare function emitLoose<K extends keyof AppEvents>(
name: K,
data: AppEvents[K]
): void;
emitLoose(eventName, { percent: 50 }); // 能通过类型检查这里 K 为两个键的联合,数据只需满足 AppEvents[K] 的某个成员。运行时 eventName 却可能为 saved,导致名字与数据不匹配。需要更严格的配对约束时,可以把每套合法参数列表分别写成元组,再组成联合。
type EmitArgs =
| ["saved", { id: string }]
| ["progress", { percent: number }];
declare function emit(...args: EmitArgs): void;
emit("saved", { id: "a" });
emit("progress", { percent: 50 });
// @ts-expect-error 名字与数据不匹配
emit("saved", { percent: 50 });
// @ts-expect-error 联合名字与这份数据无法保证成对
emit(eventName, { percent: 50 });剩余参数 args 的元组类型描述完整参数列表;调用时仍然传两个参数,不是传一个数组。每个联合成员都保留名字与数据的配对。多个参数需要关联变化时,这种写法很有用;参数互相独立时,普通参数声明通常更直接。
从映射表自动生成参数联合
type EventArgs<T> = {
[K in keyof T]: [K, T[K]]
}[keyof T];
type MappedArgs = {
saved: ["saved", { id: string }];
progress: ["progress", { percent: number }];
};
type ArgsFromMap = MappedArgs[keyof AppEvents];
// 等价于 MappedArgs["saved"] | MappedArgs["progress"]
// 结果为 ["saved", { id: string }] | ["progress", { percent: number }]映射时,每个 K 与对应的 T[K] 组成完整元组。末尾索引只从同一个映射对象中取出各属性的值类型,组成联合;没有额外交叉匹配,也没有在本例中产生额外的 never。
如果属性值本来就是 never,取值后才可能出现 never 并从联合中消去。索引不存在的属性通常会报错,不会自动得到 never。
type Example = { a: string; b: never };
type ExampleValues = Example[keyof Example]; // string | never → string
// @ts-expect-error missing 不是属性名
type MissingValue = Example["missing"];这里的事件映射使用必填属性。可选属性会影响映射结果与索引取值,不应未经处理就把本例推广到所有对象类型。更多映射与取值的基础解释见映射类型与字段筛选。
在实现中收窄整组参数
function inspectEvent(...args: EventArgs<AppEvents>) {
if (args[0] === "progress") {
// args: ["progress", { percent: number }]
console.log(args[1].percent);
} else {
console.log(args[1].id);
}
}第一个元素充当判别标记,检查它会收窄整个元组,第二个元素也随之确定。这次关联保存在同一个联合成员中,与前面检查外部 eventName 的场景不同。该函数只展示类型收窄,不是完整事件分发器。
解构之后,如何保留配对关系
反复使用 args[0]、args[1] 不利于阅读。可以在同一次 const 解构中给两个位置命名,仍然保留元组联合的关联收窄。
function handlePair(...args: EventArgs<AppEvents>) {
const [eventName, payload] = args;
if (eventName === "progress") {
console.log(payload.percent);
}
}两个变量来自同一完整配对。检查 eventName 后,payload 也被收窄。若改成两个独立的联合参数,签名便允许交叉组合,无法表达相同关系。
function handleSeparate(
eventName: "saved" | "progress",
payload: { id: string } | { percent: number }
) {
if (eventName === "progress") {
// @ts-expect-error 两个独立联合参数没有保存配对关系
console.log(payload.percent);
}
}
handleSeparate("progress", { id: "a" }); // 签名允许这种组合
// @ts-expect-error 完整元组联合不接受这种配对
handlePair("progress", { id: "a" });let 解构没有这里的关联收窄,即使暂时没有重新赋值也一样。单独改写一个变量,还可能在运行时打破最初的配对;它不会同步修改另一个变量或原元组的位置。
function inspectMutable(...args: EventArgs<AppEvents>) {
let [eventName, payload] = args;
eventName = "progress";
console.log(eventName, payload, args[0]);
// @ts-expect-error eventName 的值不能证明 payload 含有 percent
console.log(payload.percent);
}
// 若输入 ("saved", { id: "a" }),第一条日志的三个值为:
// "progress"、{ id: "a" }、"saved"这里说的是变量绑定的重新赋值;const 不会把对象深度冻结。需要修改数据时,仍应分别考虑对象的可变性和类型约束。
直接在参数位置解构
不需要保留 args 这个名字时,可以把 rest 参数和解构写在一起。对于本例这种可辨识元组联合,只要解构参数没有被重新赋值,仍可以根据事件名收窄数据。
function inspectRest(...[eventName, payload]: EventArgs<AppEvents>) {
if (eventName === "saved") {
console.log(payload.id);
}
}
inspectRest("saved", { id: "a" });
function inspectTuple([eventName, payload]: EventArgs<AppEvents>) {
if (eventName === "progress") {
console.log(payload.percent);
}
}
inspectTuple(["progress", { percent: 50 }]);有 … 时,先收集多个实参,再解构;没有 … 时,接收一个元组实参,再解构。两者的调用形式不同,但都可以用完整元组联合表达配对。上述解构关联支持见 TypeScript 4.6 官方说明。
组织处理器表:为每个事件配置逻辑
需要检查是否漏写某个处理器时,可以生成完整处理器表。按需订阅单个事件时,on 更直接;要求每种事件都有实现时,映射表更适合。
type AppHandlers = {
[K in AppEvent["kind"]]: (event: EventOf<K>) => void
};
const handlers: AppHandlers = {
saved: event => console.log(event.id.toUpperCase()),
failed: event => console.log(event.error.toUpperCase()),
progress: event => console.log(event.percent.toFixed(0)),
};
type ProgressHandler = AppHandlers["progress"];
// (event: { kind: "progress"; percent: number }) => void这里需要的结果就是处理器对象,无需末尾索引。EventArgs 则需要把映射值取成联合:是否追加索引,取决于最终希望得到对象还是联合。
多组事件可以复用同一规则:
type Handlers<Events extends { kind: string }> = {
[K in Events["kind"]]:
(event: Extract<Events, { kind: K }>) => void
};
type TaskEvent =
| { kind: "started"; taskId: string }
| { kind: "finished"; duration: number };
const taskHandlers: Handlers<TaskEvent> = {
started: event => console.log(event.taskId),
finished: event => console.log(event.duration.toFixed(1)),
};Events[“kind”] 取得名称的值类型;keyof Events 取得对象的键。对 TaskEvent,前者为 “started” | “finished”,后者只有 “kind”。本例以具体的字符串字面量事件名作处理器键,约束确保可以读取 kind。
处理器表描述形状和参数类型。动态分发的实现、解构后关联是否保留,以及函数参数的兼容性,需要结合具体代码进一步讨论。
官方阅读
- Generics 与 Indexed Access Types:从调用实参推导类型参数,按键取得载荷类型。
- Contextual Typing:回调参数从期望函数类型获得类型。
- Return type void:目标 void 函数类型的赋值规则与显式实现标注。
- Narrowing:属性检查、可辨识联合与控制流。
- Rest parameters with tuple types:用元组表示参数列表。
- Dependent parameters:用可辨识元组联合表达相关参数。
- Mapped Types、Extract 与 Conditional Types:生成处理器表、配对元组与筛选完整事件成员。
这一页,先记到这里。