TypeScript 中如何通过单一 registry 实现类型安全的 dynamic dispatch?
可以只维护一个 registry,并从中推导参数类型。给 dispatch 使用的 registry 引用显式标注映射类型后,编译器就能通过同一个泛型键 K,关联 Verb<ParamsByType[K]> 和 action.params。
这个引用直接指向原对象即可,无须复制 registry,也不用手工维护另一份参数类型表。
为什么会出现 never
第二段代码中的 verbs 被推导为具体的对象类型:
{
readonly goTo: Verb<number>;
readonly attack: Verb<string>;
}
在报错的泛型函数中,通过这个具体对象进行索引访问时,编译器没有保留所需的类型关联,调用目标因此变成了函数联合:
((params: number) => void) | ((params: string) => void)
调用这样的函数联合时,实参必须同时满足所有可能的参数要求,也就是:
number & string // never
Action<K> 已经表达了 verbType 与 params 的对应关系。dispatch 一侧也需要用编译器能处理的映射形式表达这层关系。
as const 只能保留字面量类型并使属性只读,无法解决这里的关联问题。
完整实现
在 registry 中定义各个 verb,从中推导类型,再为 dispatch 建立带有映射类型注解的引用:
type Verb<Params> = {
precondition: (params: Params) => boolean;
execute: (params: Params) => void;
};
function defineVerb<Params>(verb: Verb<Params>): Verb<Params> {
return verb;
}
// 唯一需要维护的 registry。
// 每个 verb 的参数类型与实现放在一起。
const registry = {
goTo: defineVerb<number>({
precondition: (v) => v > 0,
execute: (v) => {
console.log(v);
},
}),
attack: defineVerb<string>({
precondition: (s) => s.length > 0,
execute: (s) => {
console.log(s);
},
}),
};
// 从 registry 推导参数类型,不再手工声明对应关系。
type ParamsByType = {
[K in keyof typeof registry]:
Parameters<(typeof registry)[K]["execute"]>[0];
};
type VerbType = keyof ParamsByType;
type VerbRegistry = {
[K in VerbType]: Verb<ParamsByType[K]>;
};
// 显式类型注解是关键。
// verbs 与 registry 指向同一个对象。
const verbs: VerbRegistry = registry;
type Action<K extends VerbType = VerbType> = {
[P in K]: {
verbType: P;
params: ParamsByType[P];
};
}[K];
function executeAction<K extends VerbType>(action: Action<K>): void {
if (!verbs[action.verbType].precondition(action.params)) {
throw new Error("Precondition not met");
}
verbs[action.verbType].execute(action.params);
}
defineVerb<number> 为对象中的两个函数提供了上下文类型,它们的参数都能推导为 number,不用分别标注。
const verbs: VerbRegistry = registry 是普通赋值,编译器会检查两者是否兼容。它没有使用类型断言,也没有创建第二个 registry 对象。
关联是如何保留下来的
映射类型表达了每个键与处理函数的对应关系:
type VerbRegistry = {
[K in VerbType]: Verb<ParamsByType[K]>;
};
在 executeAction 中,处理函数和参数通过同一个键 K 关联:
// 对应的处理函数
Verb<ParamsByType[K]>
// 对应的参数
ParamsByType[K]
Action<K> 则保留了先映射、再索引的写法。当 K 是多个 verb 名称的联合时,它会生成各个合法 action 的联合:
type AnyAction = Action;
// 等价于:
// | { verbType: "goTo"; params: number }
// | { verbType: "attack"; params: string }
这样既可以执行具体的 action,也可以执行包含不同 action 的序列:
executeAction({ verbType: "goTo", params: 42 });
executeAction({ verbType: "attack", params: "sword" });
const actions: Action[] = [
{ verbType: "goTo", params: 1 },
{ verbType: "attack", params: "sword" },
];
for (const action of actions) {
executeAction(action);
}
参数不匹配时,类型检查会报错:
// @ts-expect-error goTo 的 params 必须是 number
const invalidAction: Action = { verbType: "goTo", params: "field 1" };
新增 verb 时,只需在 registry 中添加定义,ParamsByType、VerbType 和 Action 都会随之更新。
哪些写法会丢失关联
如果把 Action 简化为两个独立的联合属性:
type BadAction = {
verbType: VerbType;
params: ParamsByType[VerbType];
};
类型检查就会允许下面这种错误组合:
const invalid: BadAction = {
verbType: "goTo",
params: "sword",
};
把 registry 中所有值的类型统一写成下面这样,也会改变原有的类型约定:
Verb<number | string>
这表示每个 verb 都能处理 number 和 string,与现有实现不同。
satisfies VerbRegistry 也不能替代这里的显式类型注解。它会检查兼容性,但会保留表达式自身的推导类型。dispatch 需要通过映射类型形式的引用来访问 registry。
接入 Valibot 时的边界
后续可以把 schema 和处理函数放在同一个 verb 定义中,从 schema 的解析输出推导参数类型。如果 schema 包含转换操作,需要区分原始输入类型和解析后的输出类型,处理函数应接收后者。
从 local storage 读取的 JSON 仍需在运行时校验。下面的类型断言不会检查数据是否有效:
const action = JSON.parse(raw) as Action;
校验时,需要确认 verbType 存在,并检查 params 是否符合该 verb 对应的 schema。解析成功后,再把结果交给 executeAction。
precondition 接收的是已经符合参数类型的数据,用来检查目标位置是否可达等游戏规则,不能替代对外部数据结构的校验。
备注:内容仅供参考。
